├── .github └── FUNDING.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json └── src ├── ActionSender.php ├── Client.php ├── Factory.php └── Protocol ├── Action.php ├── Collection.php ├── ErrorException.php ├── Event.php ├── Message.php ├── Parser.php ├── Response.php └── UnexpectedMessageException.php /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: clue 2 | custom: https://clue.engineering/support 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.2.0 (2024-02-23) 4 | 5 | * Feature: Forward compatibility with Promise v3. 6 | (#80 by @SimonFrings) 7 | 8 | * Feature: Simplify usage by supporting new [default loop](https://reactphp.org/event-loop/#loop) and new Socket API. 9 | (#70 by @clue and #71 by @SimonFrings) 10 | 11 | ```php 12 | // old (still supported) 13 | $factory = new Clue\React\Ami\Factory($loop); 14 | 15 | // new (using default loop) 16 | $factory = new Clue\React\Ami\Factory(); 17 | ``` 18 | 19 | * Feature: Full PHP 8.3 compatibility. 20 | (#67, #73 and #79 by @SimonFrings) 21 | 22 | * Minor documentation improvements. 23 | (#69 by @PaulRotmann and #77 by @yadaiio) 24 | 25 | * Improve test suite and use GitHub actions for continuous integration (CI). 26 | (#67 and #78 by @SimonFrings) 27 | 28 | ## 1.1.0 (2020-10-09) 29 | 30 | * Feature: Support authentication with URL-encoded special characters. 31 | (#66 by @clue) 32 | 33 | ```php 34 | $user = 'he:llo'; 35 | $pass = 'p@ss'; 36 | $promise = $factory->createClient( 37 | rawurlencode($user) . ':' . rawurlencode($pass) . '@localhost' 38 | ); 39 | ``` 40 | 41 | * Minor documentation improvements and add support / sponsorship info. 42 | (#58 by @clue) 43 | 44 | * Improve test suite and add `.gitattributes` to exclude dev files from exports. 45 | Prepare PHP 8 support, update to PHPUnit 9 and simplify test matrix. 46 | (#57 and #59 by @clue and #61 and #65 by @SimonFrings) 47 | 48 | ## 1.0.0 (2019-10-31) 49 | 50 | * **First stable release, now following SemVer!** 51 | 52 | * Feature / Fix: Support Asterisk 14+ command output format as well as legacy format. 53 | (#54 by @clue) 54 | 55 | * Feature / Fix: Support parsing messages with multiple newlines between messages. 56 | (#53 by @glet and @clue) 57 | 58 | * Improve README and API documentation. 59 | (#55 by @clue) 60 | 61 | * Improve test suite, support PHPUnit 7 - legacy PHPUnit 4, test against legacy PHP 5.3 through PHP 7.3 62 | and update project homepage. 63 | (#51 and #52 by @clue) 64 | 65 | > Contains no other changes, so it's actually fully compatible with the v0.4.0 release. 66 | 67 | ## 0.4.0 (2017-09-04) 68 | 69 | * Feature / BC break: Simplify `Collection` by extending `Response` and merging `Collector` into `ActionSender` 70 | (#41 by @clue) 71 | 72 | ```php 73 | // old 74 | $collector = new Collector($client); 75 | $collector->coreShowChannels()->then(function (Collection $collection) { 76 | var_dump($collection->getResponse()->getFieldValue('Message')); 77 | }); 78 | 79 | // new 80 | $collector = new ActionSender($client); 81 | $collector->coreShowChannels()->then(function (Collection $collection) { 82 | var_dump($collection->getFieldValue('Message')); 83 | }); 84 | ``` 85 | 86 | * Feature / BC break: Replace deprecated SocketClient with new Socket component and 87 | improve forward compatibility with upcoming ReactPHP components 88 | (#39 by @clue) 89 | 90 | * Feature / BC break: Consistently require URL when creating client 91 | (#40 by @clue) 92 | 93 | ## 0.3.2 (2017-09-04) 94 | 95 | * Feature / Fix: Update SocketClient to v0.5 and fix secure connection via TLS 96 | (#38 by @clue) 97 | 98 | * Improve test suite by adding PHPUnit to require-dev, 99 | fix HHVM build for now again and ignore future HHVM build errors, 100 | test against legacy PHP 5.3 through PHP 7.1 and 101 | lock Travis distro so new defaults will not break the build 102 | (#34, #35, #36 and #37 by @clue) 103 | 104 | ## 0.3.1 (2016-11-01) 105 | 106 | * Fix: Make parser more robust by supporting parsing messages with missing space after colon 107 | (#29 by @bonan, @clue) 108 | 109 | * Improve documentation 110 | 111 | ## 0.3.0 (2015-03-31) 112 | 113 | * BC break: Rename `Api` to `ActionSender` to reflect its responsibility 114 | ([#22](https://github.com/clue/php-ami-react/pull/22)) 115 | 116 | * Rename invalid action method `logout()` to proper `logoff()` 117 | ([#17](https://github.com/clue/php-ami-react/issues/17)) 118 | 119 | * Feature: Add `Response::getCommandOutput()` helper 120 | ([#23](https://github.com/clue/php-ami-react/pull/23)) 121 | 122 | * Feature: Emit "error" event for unexpected response messages 123 | ([#21](https://github.com/clue/php-ami-react/pull/21)) 124 | 125 | * Functional integration test suite 126 | ([#18](https://github.com/clue/php-ami-react/pull/18) / [#24](https://github.com/clue/php-ami-react/pull/24)) 127 | 128 | ## 0.2.0 (2014-07-20) 129 | 130 | * Package renamed to "clue/ami-react" 131 | 132 | ## 0.1.0 (2014-07-17) 133 | 134 | * First tagged release 135 | 136 | ## 0.0.0 (2014-06-25) 137 | 138 | * Initial concept 139 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 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-ami 2 | 3 | [![CI status](https://github.com/clue/reactphp-ami/actions/workflows/ci.yml/badge.svg)](https://github.com/clue/reactphp-ami/actions) 4 | [![installs on Packagist](https://img.shields.io/packagist/dt/clue/ami-react?color=blue&label=installs%20on%20Packagist)](https://packagist.org/packages/clue/ami-react) 5 | 6 | Streaming, event-driven access to the Asterisk Manager Interface (AMI), 7 | built on top of [ReactPHP](https://reactphp.org). 8 | 9 | The [Asterisk PBX](https://www.asterisk.org/) is a popular open source telephony solution 10 | that offers a wide range of telephony features. 11 | The [Asterisk Manager Interface (AMI)](https://wiki.asterisk.org/wiki/display/AST/The+Asterisk+Manager+TCP+IP+API) 12 | allows you to control and monitor the PBX. 13 | Among others, it can be used to originate a new call, execute Asterisk commands or 14 | monitor the status of subscribers, channels or queues. 15 | 16 | * **Async execution of Actions** - 17 | Send any number of actions (commands) to the Asterisk service in parallel and 18 | process their responses as soon as results come in. 19 | The Promise-based design provides a *sane* interface to working with out of order responses. 20 | * **Event-driven core** - 21 | Register your event handler callbacks to react to incoming events, such as an incoming call or 22 | a change in a subscriber state. 23 | * **Lightweight, SOLID design** - 24 | Provides a thin abstraction that is [*just good enough*](https://en.wikipedia.org/wiki/Principle_of_good_enough) 25 | and does not get in your way. 26 | Future or custom actions and events require no changes to be supported. 27 | * **Good test coverage** - 28 | Comes with an automated tests suite and is regularly tested in the *real world* 29 | against current Asterisk versions and versions as old as Asterisk 1.8. 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 | * [close()](#close) 40 | * [end()](#end) 41 | * [createAction()](#createaction) 42 | * [request()](#request) 43 | * [event event](#event-event) 44 | * [error event](#error-event) 45 | * [close event](#close-event) 46 | * [ActionSender](#actionsender) 47 | * [Actions](#actions) 48 | * [Promises](#promises) 49 | * [Blocking](#blocking) 50 | * [Message](#message) 51 | * [getFieldValue()](#getfieldvalue) 52 | * [getFieldValues()](#getfieldvalues) 53 | * [getFieldVariables()](#getfieldvariables) 54 | * [getFields()](#getfields) 55 | * [getActionId()](#getactionid) 56 | * [Response](#response) 57 | * [getCommandOutput()](#getcommandoutput) 58 | * [Collection](#collection) 59 | * [getEntryEvents()](#getentryevents) 60 | * [getCompleteEvent()](#getcompleteevent) 61 | * [Action](#action) 62 | * [getMessageSerialized()](#getmessageserialized) 63 | * [Event](#event) 64 | * [getName()](#getname) 65 | * [Install](#install) 66 | * [Tests](#tests) 67 | * [License](#license) 68 | 69 | ## Support us 70 | 71 | We invest a lot of time developing, maintaining and updating our awesome 72 | open-source projects. You can help us sustain this high-quality of our work by 73 | [becoming a sponsor on GitHub](https://github.com/sponsors/clue). Sponsors get 74 | numerous benefits in return, see our [sponsoring page](https://github.com/sponsors/clue) 75 | for details. 76 | 77 | Let's take these projects to the next level together! 🚀 78 | 79 | ## Quickstart example 80 | 81 | Once [installed](#install), you can use the following code to access your local 82 | Asterisk instance and issue some simple commands via AMI: 83 | 84 | ```php 85 | createClient('user:secret@localhost')->then(function (Clue\React\Ami\Client $client) { 92 | echo 'Client connected' . PHP_EOL; 93 | 94 | $sender = new Clue\React\Ami\ActionSender($client); 95 | $sender->listCommands()->then(function (Clue\React\Ami\Protocol\Response $response) { 96 | echo 'Available commands:' . PHP_EOL; 97 | var_dump($response); 98 | }); 99 | }); 100 | ``` 101 | 102 | See also the [examples](examples/). 103 | 104 | ## Usage 105 | 106 | ### Factory 107 | 108 | The `Factory` is responsible for creating your [`Client`](#client) instance. 109 | 110 | ```php 111 | $factory = new Clue\React\Ami\Factory(); 112 | ``` 113 | 114 | This class takes an optional `LoopInterface|null $loop` parameter that can be used to 115 | pass the event loop instance to use for this object. You can use a `null` value 116 | here in order to use the [default loop](https://github.com/reactphp/event-loop#loop). 117 | This value SHOULD NOT be given unless you're sure you want to explicitly use a 118 | given event loop instance. 119 | 120 | If you need custom connector settings (DNS resolution, TLS parameters, timeouts, 121 | proxy servers etc.), you can explicitly pass a custom instance of the 122 | [`ConnectorInterface`](https://github.com/reactphp/socket#connectorinterface): 123 | 124 | ```php 125 | $connector = new React\Socket\Connector(array( 126 | 'dns' => '127.0.0.1', 127 | 'tcp' => array( 128 | 'bindto' => '192.168.10.1:0' 129 | ), 130 | 'tls' => array( 131 | 'verify_peer' => false, 132 | 'verify_peer_name' => false 133 | ) 134 | )); 135 | 136 | $factory = new Clue\React\Ami\Factory(null, $connector); 137 | ``` 138 | 139 | #### createClient() 140 | 141 | The `createClient(string $url): PromiseInterface` method can be used to 142 | create a new [`Client`](#client). 143 | 144 | It helps with establishing a plain TCP/IP or secure TLS connection to the AMI 145 | and optionally issuing an initial `login` action. 146 | 147 | ```php 148 | $factory->createClient($url)->then( 149 | function (Clue\React\Ami\Client $client) { 150 | // client connected (and authenticated) 151 | }, 152 | function (Exception $e) { 153 | // an error occurred while trying to connect or authorize client 154 | } 155 | ); 156 | ``` 157 | 158 | The method returns a [Promise](https://github.com/reactphp/promise) that will 159 | resolve with the [`Client`](#client) instance on success or will reject with an 160 | `Exception` if the URL is invalid or the connection or authentication fails. 161 | 162 | The `$url` parameter contains the host and optional port (which defaults to 163 | `5038` for plain TCP/IP connections) to connect to: 164 | 165 | ```php 166 | $factory->createClient('localhost:5038'); 167 | ``` 168 | 169 | The above example does not pass any authentication details, so you may have to 170 | call `ActionSender::login()` after connecting or use the recommended shortcut 171 | to pass a username and secret for your AMI login details like this: 172 | 173 | ```php 174 | $factory->createClient('user:secret@localhost'); 175 | ``` 176 | 177 | Note that both the username and password must be URL-encoded (percent-encoded) 178 | if they contain special characters: 179 | 180 | ```php 181 | $user = 'he:llo'; 182 | $pass = 'p@ss'; 183 | 184 | $promise = $factory->createClient( 185 | rawurlencode($user) . ':' . rawurlencode($pass) . '@localhost' 186 | ); 187 | ``` 188 | 189 | The `Factory` defaults to establishing a plaintext TCP connection. 190 | If you want to create a secure TLS connection, you can use the `tls` scheme 191 | (which defaults to port `5039`): 192 | 193 | ```php 194 | $factory->createClient('tls://user:secret@localhost:5039'); 195 | ``` 196 | 197 | ### Client 198 | 199 | The `Client` is responsible for exchanging messages with the Asterisk Manager Interface 200 | and keeps track of pending actions. 201 | 202 | If you want to send outgoing actions, see below for the [`ActionSender`](#actionsender) class. 203 | 204 | Besides defining a few methods, this interface also implements the 205 | `EventEmitterInterface` which allows you to react to certain events as documented below. 206 | 207 | #### close() 208 | 209 | The `close(): void` method can be used to 210 | force-close the AMI connection and reject all pending actions. 211 | 212 | #### end() 213 | 214 | The `end(): void` method can be used to 215 | soft-close the AMI connection once all pending actions are completed. 216 | 217 | #### createAction() 218 | 219 | The `createAction(string $name, array $fields): Action` method can be used to 220 | construct a custom AMI action. 221 | 222 | This method is considered advanced usage and mostly used internally only. 223 | Creating [`Action`](#action) objects, sending them via AMI and waiting 224 | for incoming [`Response`](#response) objects is usually hidden behind the 225 | [`ActionSender`](#actionsender) interface. 226 | 227 | If you happen to need a custom or otherwise unsupported action, you can 228 | also do so manually as follows. Consider filing a PR to add new actions 229 | to the [`ActionSender`](#actionsender). 230 | 231 | A unique value will be added to "ActionID" field automatically (needed to 232 | match the incoming responses). 233 | 234 | ```php 235 | $action = $client->createAction('Originate', array('Channel' => …)); 236 | $promise = $client->request($action); 237 | ``` 238 | 239 | #### request() 240 | 241 | The `request(Action $action): PromiseInterface` method can be used to 242 | queue the given messages to be sent via AMI 243 | and wait for a [`Response`](#response) object that matches the value of its "ActionID" field. 244 | 245 | This method is considered advanced usage and mostly used internally only. 246 | Creating [`Action`](#action) objects, sending them via AMI and waiting 247 | for incoming [`Response`](#response) objects is usually hidden behind the 248 | [`ActionSender`](#actionsender) interface. 249 | 250 | If you happen to need a custom or otherwise unsupported action, you can 251 | also do so manually as follows. Consider filing a PR to add new actions 252 | to the [`ActionSender`](#actionsender). 253 | 254 | ```php 255 | $action = $client->createAction('Originate', array('Channel' => …)); 256 | $promise = $client->request($action); 257 | ``` 258 | 259 | #### event event 260 | 261 | The `event` event (*what a lovely name*) will be emitted whenever AMI sends an event, such as 262 | a phone call that just started or ended and much more. 263 | The event receives a single [`Event`](#event) argument describing the event instance. 264 | 265 | ```php 266 | $client->on('event', function (Clue\React\Ami\Protocol\Event $event) { 267 | // process an incoming AMI event (see below) 268 | var_dump($event->getName(), $event); 269 | }); 270 | ``` 271 | 272 | Event reporting can be turned on/off via AMI configuration and the [`events()` action](#actions). 273 | The [`events()` action](#actions) can also be used to enable an "EventMask" to 274 | only report certain events as per the [AMI documentation](https://wiki.asterisk.org/wiki/display/AST/Asterisk+14+ManagerAction_Events). 275 | 276 | See also [AMI Events documentation](https://wiki.asterisk.org/wiki/display/AST/Asterisk+14+AMI+Events) 277 | for more details about event types and their respective fields. 278 | 279 | #### error event 280 | 281 | The `error` event will be emitted once a fatal error occurs, such as 282 | when the client connection is lost or is invalid. 283 | The event receives a single `Exception` argument for the error instance. 284 | 285 | ```php 286 | $client->on('error', function (Exception $e) { 287 | echo 'Error: ' . $e->getMessage() . PHP_EOL; 288 | }); 289 | ``` 290 | 291 | This event will only be triggered for fatal errors and will be followed 292 | by closing the client connection. It is not to be confused with "soft" 293 | errors caused by invalid commands. 294 | 295 | #### close event 296 | 297 | The `close` event will be emitted once the client connection closes (terminates). 298 | 299 | ```php 300 | $client->on('close', function () { 301 | echo 'Connection closed' . PHP_EOL; 302 | }); 303 | ``` 304 | 305 | See also the [`close()`](#close) method. 306 | 307 | ### ActionSender 308 | 309 | The `ActionSender` wraps a given [`Client`](#client) instance to provide a simple way to execute common actions. 310 | This class represents the main interface to execute actions and wait for the corresponding responses. 311 | 312 | ```php 313 | $sender = new Clue\React\Ami\ActionSender($client); 314 | ``` 315 | 316 | #### Actions 317 | 318 | All public methods resemble their respective AMI actions. 319 | 320 | ```php 321 | $sender->login($name, $pass); 322 | $sender->logoff(); 323 | $sender->ping(); 324 | $sender->command($command); 325 | $sender->events($eventMask); 326 | 327 | $sender->coreShowChannels(); 328 | $sender->sipPeers(); 329 | $sender->agents(); 330 | 331 | // many more… 332 | ``` 333 | 334 | Listing all available actions is out of scope here, please refer to the [class outline](src/ActionSender.php). 335 | 336 | Note that using the `ActionSender` is not strictly necessary, but is the recommended way to execute common actions. 337 | 338 | If you happen to need a custom or otherwise unsupported action, you can 339 | also do so manually. See the advanced [`createAction()`](#createaction) usage above for details. 340 | Consider filing a PR to add new actions to the `ActionSender`. 341 | 342 | #### Promises 343 | 344 | Sending actions is async (non-blocking), so you can actually send multiple 345 | action requests in parallel. 346 | The AMI will respond to each action with a [`Response`](#response) object. 347 | The order is not guaranteed. 348 | Sending actions uses a [Promise](https://github.com/reactphp/promise)-based 349 | interface that makes it easy to react to when an action is completed 350 | (i.e. either successfully fulfilled or rejected with an error): 351 | 352 | ```php 353 | $sender->ping()->then( 354 | function (Clue\React\Ami\Protocol\Response $response) { 355 | // response received for ping action 356 | }, 357 | function (Exception $e) { 358 | // an error occurred while executing the action 359 | 360 | if ($e instanceof Clue\React\Ami\Protocol\ErrorException) { 361 | // we received a valid error response (such as authorization error) 362 | $response = $e->getResponse(); 363 | } else { 364 | // we did not receive a valid response (likely a transport issue) 365 | } 366 | } 367 | }); 368 | ``` 369 | 370 | All actions resolve with a [`Response`](#response) object on success, 371 | some actions are documented to return the specialized [`Collection`](#collection) 372 | object to contain a list of entries. 373 | 374 | #### Blocking 375 | 376 | As stated above, this library provides you a powerful, async API by default. 377 | 378 | If, however, you want to integrate this into your traditional, blocking environment, 379 | you should look into also using [clue/reactphp-block](https://github.com/clue/reactphp-block). 380 | 381 | The resulting blocking code could look something like this: 382 | 383 | ```php 384 | use Clue\React\Block; 385 | use React\EventLoop\Loop; 386 | 387 | function getSipPeers() 388 | { 389 | $factory = new Clue\React\Ami\Factory(); 390 | 391 | $target = 'name:password@localhost'; 392 | $promise = $factory->createClient($target)->then(function (Clue\React\Ami\Client $client) { 393 | $sender = new Clue\React\Ami\ActionSender($client); 394 | $ret = $sender->sipPeers()->then(function (Clue\React\Ami\Collection $collection) { 395 | return $collection->getEntryEvents(); 396 | }); 397 | $client->end(); 398 | return $ret; 399 | }); 400 | 401 | return Block\await($promise, Loop::get(), 5.0); 402 | } 403 | ``` 404 | 405 | Refer to [clue/reactphp-block](https://github.com/clue/reactphp-block#readme) for more details. 406 | 407 | ### Message 408 | 409 | The `Message` is an abstract base class for the [`Response`](#response), 410 | [`Action`](#action) and [`Event`](#event) value objects. 411 | It provides a common interface for these three message types. 412 | 413 | Each `Message` consists of any number of fields with each having a name and one or multiple values. 414 | Field names are matched case-insensitive. The interpretation of values is application-specific. 415 | 416 | #### getFieldValue() 417 | 418 | The `getFieldValue(string $key): ?string` method can be used to 419 | get the first value for the given field key. 420 | 421 | If no value was found, `null` is returned. 422 | 423 | #### getFieldValues() 424 | 425 | The `getFieldValues(string $key): string[]` method can be used to 426 | get a list of all values for the given field key. 427 | 428 | If no value was found, an empty `array()` is returned. 429 | 430 | #### getFieldVariables() 431 | 432 | The `getFieldVariables(string $key): array` method can be used to 433 | get a hashmap of all variable assignments in the given $key. 434 | 435 | If no value was found, an empty `array()` is returned. 436 | 437 | #### getFields() 438 | 439 | The `getFields(): array` method can be used to 440 | get an array of all fields. 441 | 442 | #### getActionId() 443 | 444 | The `getActionId(): string` method can be used to 445 | get the unique action ID of this message. 446 | 447 | This is a shortcut to get the value of the "ActionID" field. 448 | 449 | ### Response 450 | 451 | The `Response` value object represents the incoming response received from the AMI. 452 | It shares all properties of the [`Message`](#message) parent class. 453 | 454 | #### getCommandOutput() 455 | 456 | The `getCommandOutput(): ?string` method can be used to get the resulting output of 457 | a "command" [`Action`](#action). 458 | This value is only available if this is actually a response to a "command" action, 459 | otherwise it defaults to `null`. 460 | 461 | ```php 462 | $sender->command('help')->then(function (Clue\React\Ami\Protocol\Response $response) { 463 | echo $response->getCommandOutput(); 464 | }); 465 | ``` 466 | 467 | ### Collection 468 | 469 | The `Collection` value object represents an incoming response received from the AMI 470 | for certain actions that return a list of entries. 471 | It shares all properties of the [`Response`](#response) parent class. 472 | 473 | You can access the `Collection` like a normal `Response` in order to access 474 | the leading `Response` for this collection or you can use the below methods 475 | to access the list entries and completion event. 476 | 477 | ``` 478 | Action: CoreShowChannels 479 | 480 | Response: Success 481 | EventList: start 482 | Message: Channels will follow 483 | 484 | Event: CoreShowChannel 485 | Channel: SIP / 123 486 | ChannelState: 6 487 | ChannelStateDesc: Up 488 | … 489 | 490 | Event: CoreShowChannel 491 | Channel: SIP / 456 492 | ChannelState: 6 493 | ChannelStateDesc: Up 494 | … 495 | 496 | Event: CoreShowChannel 497 | Channel: SIP / 789 498 | ChannelState: 6 499 | ChannelStateDesc: Up 500 | … 501 | 502 | Event: CoreShowChannelsComplete 503 | EventList: Complete 504 | ListItems: 3 505 | ``` 506 | 507 | #### getEntryEvents() 508 | 509 | The `getEntryEvents(): Event[]` method can be used to 510 | get the list of all intermediary `Event` objects where each entry represents a single entry in the collection. 511 | 512 | ```php 513 | foreach ($collection->getEntryEvents() as $entry) { 514 | assert($entry instanceof Clue\React\Ami\Protocol\Event); 515 | echo $entry->getFieldValue('Channel') . PHP_EOL; 516 | } 517 | ``` 518 | 519 | #### getCompleteEvent() 520 | 521 | The `getCompleteEvent(): Event` method can be used to 522 | get the trailing `Event` that completes this collection. 523 | 524 | ```php 525 | echo $collection->getCompleteEvent()->getFieldValue('ListItems') . PHP_EOL; 526 | ``` 527 | 528 | ### Action 529 | 530 | The `Action` value object represents an outgoing action message to be sent to the AMI. 531 | It shares all properties of the [`Message`](#message) parent class. 532 | 533 | #### getMessageSerialized() 534 | 535 | The `getMessageSerialized(): string` method can be used to 536 | get the serialized version of this outgoing action to send to Asterisk. 537 | 538 | This method is considered advanced usage and mostly used internally only. 539 | 540 | ### Event 541 | 542 | The `Event` value object represents the incoming event received from the AMI. 543 | It shares all properties of the [`Message`](#message) parent class. 544 | 545 | #### getName() 546 | 547 | The `getName(): ?string` method can be used to get the name of the event. 548 | 549 | This is a shortcut to get the value of the "Event" field. 550 | 551 | ## Install 552 | 553 | The recommended way to install this library is [through Composer](https://getcomposer.org/). 554 | [New to Composer?](https://getcomposer.org/doc/00-intro.md) 555 | 556 | This project follows [SemVer](https://semver.org/). 557 | This will install the latest supported version: 558 | 559 | ```bash 560 | composer require clue/ami-react:^1.2 561 | ``` 562 | 563 | See also the [CHANGELOG](CHANGELOG.md) for details about version upgrades. 564 | 565 | This project aims to run on any platform and thus does not require any PHP 566 | extensions and supports running on legacy PHP 5.3 through current PHP 8+. 567 | It's *highly recommended to use the latest supported PHP version* for this project. 568 | 569 | ## Tests 570 | 571 | To run the test suite, you first need to clone this repo and then install all 572 | dependencies [through Composer](https://getcomposer.org/): 573 | 574 | ```bash 575 | composer install 576 | ``` 577 | 578 | To run the test suite, go to the project root and run: 579 | 580 | ```bash 581 | vendor/bin/phpunit 582 | ``` 583 | 584 | The test suite contains both unit tests and functional integration tests. 585 | The functional tests require access to a running Asterisk server instance 586 | and will be skipped by default. 587 | If you want to also run the functional tests, you need to supply *your* AMI login 588 | details in an environment variable like this: 589 | 590 | ```bash 591 | LOGIN=username:password@localhost php vendor/bin/phpunit 592 | ``` 593 | 594 | ## License 595 | 596 | This project is released under the permissive [MIT license](LICENSE). 597 | 598 | > Did you know that I offer custom development services and issuing invoices for 599 | sponsorships of releases and for contributions? Contact me (@clue) for details. 600 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "clue/ami-react", 3 | "description": "Streaming, event-driven access to the Asterisk Manager Interface (AMI), built on top of ReactPHP.", 4 | "keywords": ["Asterisk Manager Interface", "AMI", "async", "ReactPHP"], 5 | "homepage": "https://github.com/clue/reactphp-ami", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Christian Lück", 10 | "email": "christian@clue.engineering" 11 | } 12 | ], 13 | "require": { 14 | "php": ">=5.3", 15 | "evenement/evenement": "^3.0 || ^2.0 || ^1.0", 16 | "react/event-loop": "^1.2", 17 | "react/promise": "^3.0 || ^2.9 || ^1.1", 18 | "react/socket": "^1.14" 19 | }, 20 | "require-dev": { 21 | "clue/block-react": "^1.5", 22 | "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" 23 | }, 24 | "autoload": { 25 | "psr-4": { 26 | "Clue\\React\\Ami\\": "src/" 27 | } 28 | }, 29 | "autoload-dev": { 30 | "psr-4": { 31 | "Clue\\Tests\\React\\Ami\\": "tests/" 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/ActionSender.php: -------------------------------------------------------------------------------- 1 | client = $client; 25 | } 26 | 27 | /** 28 | * @param string $username 29 | * @param string $secret 30 | * @param ?bool $events 31 | * @return \React\Promise\PromiseInterface 32 | * @link https://wiki.asterisk.org/wiki/display/AST/Asterisk+14+ManagerAction_Login 33 | */ 34 | public function login($username, $secret, $events = null) 35 | { 36 | $events = $this->boolParam($events); 37 | return $this->request('Login', array('UserName' => $username, 'Secret' => $secret, 'Events' => $events)); 38 | } 39 | 40 | /** 41 | * @return \React\Promise\PromiseInterface 42 | * @link https://wiki.asterisk.org/wiki/display/AST/Asterisk+14+ManagerAction_Logoff 43 | */ 44 | public function logoff() 45 | { 46 | return $this->request('Logoff'); 47 | } 48 | 49 | /** 50 | * @param string $agentId 51 | * @param bool $soft 52 | * @return \React\Promise\PromiseInterface 53 | * @link https://wiki.asterisk.org/wiki/display/AST/Asterisk+14+ManagerAction_AgentLogoff 54 | */ 55 | public function agentLogoff($agentId, $soft = false) 56 | { 57 | $bool = $soft ? 'true' : 'false'; 58 | return $this->request('AgentLogoff', array('Agent' => $agentId, 'Soft' => $bool)); 59 | } 60 | 61 | /** 62 | * @return \React\Promise\PromiseInterface 63 | * @link https://wiki.asterisk.org/wiki/display/AST/Asterisk+14+ManagerAction_Ping 64 | */ 65 | public function ping() 66 | { 67 | return $this->request('Ping'); 68 | } 69 | 70 | /** 71 | * @param string $command 72 | * @return \React\Promise\PromiseInterface 73 | * @link https://wiki.asterisk.org/wiki/display/AST/Asterisk+14+ManagerAction_Command 74 | */ 75 | public function command($command) 76 | { 77 | return $this->request('Command', array('Command' => $command)); 78 | } 79 | 80 | /** 81 | * @param bool|string[] $eventMask 82 | * @return \React\Promise\PromiseInterface 83 | * @link https://wiki.asterisk.org/wiki/display/AST/Asterisk+14+ManagerAction_Events 84 | */ 85 | public function events($eventMask) 86 | { 87 | if ($eventMask === false) { 88 | $eventMask = 'off'; 89 | } elseif ($eventMask === true) { 90 | $eventMask = 'on'; 91 | } else { 92 | $eventMask = implode(',', $eventMask); 93 | } 94 | 95 | return $this->request('Events', array('EventMask' => $eventMask)); 96 | } 97 | 98 | /** 99 | * @param string $peerName 100 | * @return \React\Promise\PromiseInterface 101 | * @link https://wiki.asterisk.org/wiki/display/AST/Asterisk+14+ManagerAction_SIPshowpeer 102 | */ 103 | public function sipShowPeer($peerName) 104 | { 105 | return $this->request('SIPshowpeer', array('Peer' => $peerName)); 106 | } 107 | 108 | /** 109 | * @return \React\Promise\PromiseInterface 110 | * @link https://wiki.asterisk.org/wiki/display/AST/Asterisk+14+ManagerAction_ListCommands 111 | */ 112 | public function listCommands() 113 | { 114 | return $this->request('ListCommands'); 115 | } 116 | 117 | /** 118 | * @param string $channel 119 | * @param string $message 120 | * @return \React\Promise\PromiseInterface 121 | * @link https://wiki.asterisk.org/wiki/display/AST/Asterisk+14+ManagerAction_SendText 122 | */ 123 | public function sendText($channel, $message) 124 | { 125 | return $this->request('Sendtext', array('Channel' => $channel, 'Message' => $message)); 126 | } 127 | 128 | /** 129 | * @param string $channel 130 | * @param int $cause 131 | * @return \React\Promise\PromiseInterface 132 | * @link https://wiki.asterisk.org/wiki/display/AST/Asterisk+14+ManagerAction_Hangup 133 | */ 134 | public function hangup($channel, $cause) 135 | { 136 | return $this->request('Hangup', array('Channel' => $channel, 'Cause' => (string) $cause)); 137 | } 138 | 139 | /** 140 | * @param string $authType 141 | * @return \React\Promise\PromiseInterface 142 | * @link https://wiki.asterisk.org/wiki/display/AST/Asterisk+14+ManagerAction_Challenge 143 | */ 144 | public function challenge($authType = 'MD5') 145 | { 146 | return $this->request('Challenge', array('AuthType' => $authType)); 147 | } 148 | 149 | /** 150 | * @param string $filename 151 | * @param ?string $category 152 | * @return \React\Promise\PromiseInterface 153 | * @link https://wiki.asterisk.org/wiki/display/AST/Asterisk+14+ManagerAction_GetConfig 154 | */ 155 | public function getConfig($filename, $category = null) 156 | { 157 | return $this->request('GetConfig', array('Filename' => $filename, 'Category' => $category)); 158 | } 159 | 160 | /** 161 | * @return \React\Promise\PromiseInterface collection with "Event: CoreShowChannel" 162 | * @link https://wiki.asterisk.org/wiki/display/AST/Asterisk+14+ManagerAction_CoreShowChannels 163 | */ 164 | public function coreShowChannels() 165 | { 166 | return $this->collectEvents('CoreShowChannels', 'CoreShowChannelsComplete'); 167 | } 168 | 169 | /** 170 | * @return \React\Promise\PromiseInterface collection with "Event: PeerEntry" 171 | * @link https://wiki.asterisk.org/wiki/display/AST/Asterisk+14+ManagerAction_SIPpeers 172 | */ 173 | public function sipPeers() 174 | { 175 | return $this->collectEvents('SIPPeers', 'PeerlistComplete'); 176 | } 177 | 178 | /** 179 | * @return \React\Promise\PromiseInterfacecollection with "Event: Agents" 180 | * @link https://wiki.asterisk.org/wiki/display/AST/Asterisk+14+ManagerAction_Agents 181 | */ 182 | public function agents() 183 | { 184 | return $this->collectEvents('Agents', 'AgentsComplete'); 185 | } 186 | 187 | /** 188 | * @param mixed $value 189 | * @return ?string 190 | */ 191 | private function boolParam($value) 192 | { 193 | if ($value === true) { 194 | return 'on'; 195 | } 196 | if ($value === false) { 197 | return 'off'; 198 | } 199 | return null; 200 | } 201 | 202 | /** 203 | * @param string $name 204 | * @param array $args 205 | * @return \React\Promise\PromiseInterface 206 | */ 207 | private function request($name, array $args = array()) 208 | { 209 | return $this->client->request($this->client->createAction($name, $args)); 210 | } 211 | 212 | /** 213 | * @param string $command 214 | * @param string $expectedEndEvent 215 | * @return \React\Promise\PromiseInterface 216 | */ 217 | private function collectEvents($command, $expectedEndEvent) 218 | { 219 | $req = $this->client->createAction($command); 220 | $ret = $this->client->request($req); 221 | $id = $req->getActionId(); 222 | 223 | $deferred = new Deferred(); 224 | 225 | // collect all intermediary channel events with this action ID 226 | $collected = array(); 227 | $collector = function (Event $event) use ($id, &$collected, $deferred, $expectedEndEvent) { 228 | if ($event->getActionId() === $id) { 229 | $collected []= $event; 230 | 231 | if ($event->getName() === $expectedEndEvent) { 232 | $deferred->resolve($collected); 233 | } 234 | } 235 | }; 236 | $this->client->on('event', $collector); 237 | 238 | // unregister collector if client fails 239 | $client = $this->client; 240 | $unregister = function () use ($client, $collector) { 241 | $client->removeListener('event', $collector); 242 | }; 243 | $ret->then(null, $unregister); 244 | 245 | // stop waiting for events 246 | $deferred->promise()->then($unregister); 247 | 248 | return $ret->then(function (Response $response) use ($deferred) { 249 | // final result has been received => merge all intermediary channel events 250 | return $deferred->promise()->then(function ($collected) use ($response) { 251 | $last = array_pop($collected); 252 | return new Collection($response, $collected, $last); 253 | }); 254 | }); 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /src/Client.php: -------------------------------------------------------------------------------- 1 | stream = $stream; 42 | 43 | $that = $this; 44 | $this->stream->on('data', function ($chunk) use ($parser, $that) { 45 | try { 46 | $messages = $parser->push($chunk); 47 | } catch (UnexpectedValueException $e) { 48 | $that->emit('error', array($e, $that)); 49 | return; 50 | } 51 | 52 | foreach ($messages as $message) { 53 | $that->handleMessage($message); 54 | } 55 | }); 56 | 57 | $this->on('error', array($that, 'close')); 58 | 59 | $this->stream->on('close', array ($that, 'close')); 60 | } 61 | 62 | /** 63 | * Queue the given messages to be sent via AMI 64 | * and wait for a [`Response`](#response) object that matches the value of its "ActionID" field. 65 | * 66 | * This method is considered advanced usage and mostly used internally only. 67 | * Creating [`Action`](#action) objects, sending them via AMI and waiting 68 | * for incoming [`Response`](#response) objects is usually hidden behind the 69 | * [`ActionSender`](#actionsender) interface. 70 | * 71 | * If you happen to need a custom or otherwise unsupported action, you can 72 | * also do so manually as follows. Consider filing a PR to add new actions 73 | * to the [`ActionSender`](#actionsender). 74 | * 75 | * ```php 76 | * $action = $client->createAction('Originate', array('Channel' => …)); 77 | * $promise = $client->request($action); 78 | * ``` 79 | * 80 | * @param Action $message 81 | * @return \React\Promise\PromiseInterface 82 | */ 83 | public function request(Action $message) 84 | { 85 | $deferred = new Deferred(); 86 | 87 | if ($this->ending) { 88 | $deferred->reject(new Exception('Already ending')); 89 | } else { 90 | $out = $message->getMessageSerialized(); 91 | //var_dump('out', $out); 92 | $this->stream->write($out); 93 | $this->pending[$message->getActionId()] = $deferred; 94 | } 95 | 96 | return $deferred->promise(); 97 | } 98 | 99 | /** @internal */ 100 | public function handleMessage(Message $message) 101 | { 102 | if ($message instanceof Event) { 103 | $this->emit('event', array($message)); 104 | return; 105 | } 106 | 107 | assert($message instanceof Response); 108 | $id = $message->getActionId(); 109 | if (!isset($this->pending[$id])) { 110 | $this->emit('error', array(new UnexpectedMessageException($message), $this)); 111 | return; 112 | } 113 | 114 | if ($message->getFieldValue('Response') === 'Error') { 115 | $this->pending[$id]->reject(new ErrorException($message)); 116 | } else { 117 | $this->pending[$id]->resolve($message); 118 | } 119 | unset($this->pending[$id]); 120 | 121 | // last pending messages received => close client 122 | if ($this->ending && !$this->pending) { 123 | $this->close(); 124 | } 125 | } 126 | 127 | /** 128 | * Force-close the AMI connection and reject all pending actions. 129 | * 130 | * @return void 131 | */ 132 | public function close() 133 | { 134 | if ($this->stream === null) { 135 | return; 136 | } 137 | 138 | $this->ending = true; 139 | 140 | $stream = $this->stream; 141 | $this->stream = null; 142 | $stream->close(); 143 | 144 | $this->emit('close', array($this)); 145 | 146 | // reject all remaining/pending requests 147 | foreach ($this->pending as $deferred) { 148 | $deferred->reject(new Exception('Client closing')); 149 | } 150 | $this->pending = array(); 151 | } 152 | 153 | /** 154 | * Soft-close the AMI connection once all pending actions are completed. 155 | * 156 | * @return void 157 | */ 158 | public function end() 159 | { 160 | $this->ending = true; 161 | 162 | if (!$this->isBusy()) { 163 | $this->close(); 164 | } 165 | } 166 | 167 | public function isBusy() 168 | { 169 | return !!$this->pending; 170 | } 171 | 172 | /** 173 | * Construct a custom AMI action. 174 | * 175 | * This method is considered advanced usage and mostly used internally only. 176 | * Creating [`Action`](#action) objects, sending them via AMI and waiting 177 | * for incoming [`Response`](#response) objects is usually hidden behind the 178 | * [`ActionSender`](#actionsender) interface. 179 | * 180 | * If you happen to need a custom or otherwise unsupported action, you can 181 | * also do so manually as follows. Consider filing a PR to add new actions 182 | * to the [`ActionSender`](#actionsender). 183 | * 184 | * A unique value will be added to "ActionID" field automatically (needed to 185 | * match the incoming responses). 186 | * 187 | * ```php 188 | * $action = $client->createAction('Originate', array('Channel' => …)); 189 | * $promise = $client->request($action); 190 | * ``` 191 | * 192 | * @param string $name 193 | * @param array $args 194 | * @return Action 195 | */ 196 | public function createAction($name, array $args = array()) 197 | { 198 | $args = array('Action' => $name, 'ActionID' => (string)++$this->actionId) + $args; 199 | 200 | return new Action($args); 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/Factory.php: -------------------------------------------------------------------------------- 1 | '127.0.0.1', 30 | * 'tcp' => array( 31 | * 'bindto' => '192.168.10.1:0' 32 | * ), 33 | * 'tls' => array( 34 | * 'verify_peer' => false, 35 | * 'verify_peer_name' => false 36 | * ) 37 | * )); 38 | * 39 | * $factory = new Clue\React\Ami\Factory(null, $connector); 40 | * ``` 41 | */ 42 | class Factory 43 | { 44 | private $connector; 45 | 46 | public function __construct(LoopInterface $loop = null, ConnectorInterface $connector = null) 47 | { 48 | if ($connector === null) { 49 | $connector = new Connector(array(), $loop); 50 | } 51 | 52 | $this->connector = $connector; 53 | } 54 | 55 | /** 56 | * Create a new [`Client`](#client). 57 | * 58 | * It helps with establishing a plain TCP/IP or secure TLS connection to the AMI 59 | * and optionally issuing an initial `login` action. 60 | * 61 | * ```php 62 | * $factory->createClient($url)->then( 63 | * function (Clue\React\Ami\Client $client) { 64 | * // client connected (and authenticated) 65 | * }, 66 | * function (Exception $e) { 67 | * // an error occurred while trying to connect or authorize client 68 | * } 69 | * ); 70 | * ``` 71 | * 72 | * The method returns a [Promise](https://github.com/reactphp/promise) that will 73 | * resolve with the [`Client`](#client) instance on success or will reject with an 74 | * `Exception` if the URL is invalid or the connection or authentication fails. 75 | * 76 | * The `$url` parameter contains the host and optional port (which defaults to 77 | * `5038` for plain TCP/IP connections) to connect to: 78 | * 79 | * ```php 80 | * $factory->createClient('localhost:5038'); 81 | * ``` 82 | * 83 | * The above example does not pass any authentication details, so you may have to 84 | * call `ActionSender::login()` after connecting or use the recommended shortcut 85 | * to pass a username and secret for your AMI login details like this: 86 | * 87 | * ```php 88 | * $factory->createClient('user:secret@localhost'); 89 | * ``` 90 | * 91 | * Note that both the username and password must be URL-encoded (percent-encoded) 92 | * if they contain special characters: 93 | * 94 | * ```php 95 | * $user = 'he:llo'; 96 | * $pass = 'p@ss'; 97 | * 98 | * $promise = $factory->createClient( 99 | * rawurlencode($user) . ':' . rawurlencode($pass) . '@localhost' 100 | * ); 101 | * ``` 102 | * 103 | * The `Factory` defaults to establishing a plaintext TCP connection. 104 | * If you want to create a secure TLS connection, you can use the `tls` scheme 105 | * (which defaults to port `5039`): 106 | * 107 | * ```php 108 | * $factory->createClient('tls://user:secret@localhost:5039'); 109 | * ``` 110 | * 111 | * @param string $url 112 | * @return \React\Promise\PromiseInterface 113 | */ 114 | public function createClient($url) 115 | { 116 | $parts = parse_url((strpos($url, '://') === false ? 'tcp://' : '') . $url); 117 | if (!$parts || !isset($parts['scheme'], $parts['host'])) { 118 | return \React\Promise\reject(new \InvalidArgumentException('Given URL "' . $url . '" can not be parsed')); 119 | } 120 | 121 | // use default port 5039 for `tls://` or 5038 otherwise 122 | if (!isset($parts['port'])) { 123 | $parts['port'] = $parts['scheme'] === 'tls' ? 5039 : 5038; 124 | } 125 | 126 | $promise = $this->connector->connect($parts['scheme'] . '://' . $parts['host'] . ':' . $parts['port'])->then(function (ConnectionInterface $stream) { 127 | return new Client($stream); 128 | }); 129 | 130 | if (isset($parts['user'])) { 131 | $promise = $promise->then(function (Client $client) use ($parts) { 132 | $sender = new ActionSender($client); 133 | 134 | return $sender->login(rawurldecode($parts['user']), rawurldecode($parts['pass']))->then( 135 | function () use ($client) { 136 | return $client; 137 | }, 138 | function ($error) use ($client) { 139 | $client->close(); 140 | throw $error; 141 | } 142 | ); 143 | }); 144 | } 145 | 146 | return $promise; 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/Protocol/Action.php: -------------------------------------------------------------------------------- 1 | &$value) { 14 | if (is_array($value)) { 15 | foreach ($value as $k => &$v) { 16 | if ($v === null) { 17 | unset($value[$k]); 18 | } elseif (!is_int($k)) { 19 | $v = $k . '=' . $v; 20 | } 21 | } 22 | $value = array_values($value); 23 | } 24 | 25 | if ($value === null || $value === array()) { 26 | unset($fields[$key]); 27 | } 28 | } 29 | $this->fields = $fields; 30 | } 31 | 32 | /** 33 | * Get the serialized version of this outgoing action to send to Asterisk. 34 | * 35 | * This method is considered advanced usage and mostly used internally only. 36 | * 37 | * @return string 38 | */ 39 | public function getMessageSerialized() 40 | { 41 | $message = ''; 42 | foreach ($this->fields as $key => $values) { 43 | if (!is_array($values)) { 44 | $values = array($values); 45 | } 46 | foreach ($values as $value) { 47 | $message .= $key . ': ' . $value . "\r\n"; 48 | } 49 | } 50 | $message .= "\r\n"; 51 | 52 | return $message; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Protocol/Collection.php: -------------------------------------------------------------------------------- 1 | fields = $response->getFields(); 52 | $this->entryEvents = $entryEvents; 53 | $this->completeEvent = $completeEvent; 54 | } 55 | 56 | /** 57 | * Get the list of all intermediary `Event` objects where each entry represents a single entry in the collection. 58 | * 59 | * ```php 60 | * foreach ($collection->getEntryEvents() as $entry) { 61 | * assert($entry instanceof Clue\React\Ami\Protocol\Event); 62 | * echo $entry->getFieldValue('Channel') . PHP_EOL; 63 | * } 64 | * ``` 65 | * 66 | * @return Event[] 67 | */ 68 | public function getEntryEvents() 69 | { 70 | return $this->entryEvents; 71 | } 72 | 73 | /** 74 | * Get the trailing `Event` that completes this collection. 75 | * 76 | * ```php 77 | * echo $collection->getCompleteEvent()->getFieldValue('ListItems') . PHP_EOL; 78 | * ``` 79 | * 80 | * @return Event 81 | */ 82 | public function getCompleteEvent() 83 | { 84 | return $this->completeEvent; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Protocol/ErrorException.php: -------------------------------------------------------------------------------- 1 | getFieldValue('Message') . '"'); 14 | $this->response = $response; 15 | } 16 | 17 | public function getResponse() 18 | { 19 | return $this->response; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Protocol/Event.php: -------------------------------------------------------------------------------- 1 | fields = $fields; 14 | } 15 | 16 | /** 17 | * Get the name of the event. 18 | * 19 | * This is a shortcut to get the value of the "Event" field. 20 | * 21 | * @return ?string 22 | */ 23 | public function getName() 24 | { 25 | return $this->getFieldValue('Event'); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Protocol/Message.php: -------------------------------------------------------------------------------- 1 | getFieldValue('ActionId'); 27 | } 28 | 29 | /** 30 | * Get the first value for the given field key. 31 | * 32 | * If no value was found, `null` is returned. 33 | * 34 | * @param string $key 35 | * @return ?string 36 | */ 37 | public function getFieldValue($key) 38 | { 39 | $key = strtolower($key); 40 | 41 | foreach ($this->fields as $part => $value) { 42 | if (strtolower($part) === $key) { 43 | if (is_array($value)) { 44 | return reset($value); 45 | } else { 46 | return $value; 47 | } 48 | } 49 | } 50 | 51 | return null; 52 | } 53 | 54 | /** 55 | * Get a list of all values for the given field key. 56 | * 57 | * If no value was found, an empty `array()` is returned. 58 | * 59 | * @param string $key 60 | * @return string[] 61 | */ 62 | public function getFieldValues($key) 63 | { 64 | $values = array(); 65 | $key = strtolower($key); 66 | 67 | foreach ($this->fields as $part => $value) { 68 | if (strtolower($part) === $key) { 69 | if (is_array($value)) { 70 | foreach ($value as $v) { 71 | $values []= $v; 72 | } 73 | } else { 74 | $values []= $value; 75 | } 76 | } 77 | } 78 | 79 | return $values; 80 | } 81 | 82 | /** 83 | * Get a hashmap of all variable assignments in the given $key. 84 | * 85 | * If no value was found, an empty `array()` is returned. 86 | * 87 | * @param string $key 88 | * @return array 89 | * @uses self::getFieldValues() 90 | */ 91 | public function getFieldVariables($key) 92 | { 93 | $variables = array(); 94 | 95 | foreach ($this->getFieldValues($key) as $value) { 96 | $temp = explode('=', $value, 2); 97 | $variables[$temp[0]] = $temp[1]; 98 | } 99 | 100 | return $variables; 101 | } 102 | 103 | /** 104 | * Get an array of all fields. 105 | * 106 | * @return array 107 | */ 108 | public function getFields() 109 | { 110 | return $this->fields; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/Protocol/Parser.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | public function push($chunk) 24 | { 25 | $this->buffer .= $chunk; 26 | $messages = array(); 27 | 28 | if (!$this->gotInitial && ($pos = strpos($this->buffer, self::EOL)) !== false) { 29 | //var_dump('initial', substr($this->buffer, 0, $pos)); 30 | $this->gotInitial = true; 31 | $this->buffer = (string)substr($this->buffer, $pos + self::LEOL); 32 | } 33 | 34 | while (($pos = strpos($this->buffer, self::EOM)) !== false) { 35 | $message = substr($this->buffer, 0, $pos); 36 | $this->buffer = (string)substr($this->buffer, $pos + self::LEOM); 37 | 38 | $parsed = $this->parseMessage($message); 39 | if ($parsed->getFields()) { 40 | $messages []= $parsed; 41 | } 42 | } 43 | 44 | return $messages; 45 | } 46 | 47 | private function parseMessage($message) 48 | { 49 | $lines = array_filter(explode(self::EOL, $message)); 50 | $last = count($lines) - 1; 51 | $fields = array(); 52 | 53 | foreach ($lines as $i => $line) { 54 | $pos = strlen($line) - self::LCOMMAND_END - 1; 55 | if ($i === $last && substr($line, -self::LCOMMAND_END) === self::COMMAND_END && ($pos < 0 || $line[$pos] === "\n")) { 56 | $key = Response::FIELD_COMMAND_OUTPUT; 57 | $value = $line; 58 | } else { 59 | $pos = strpos($line, ':'); 60 | if ($pos === false) { 61 | throw new \UnexpectedValueException('Parse error, no colon in line "' . $line . '" found'); 62 | } 63 | 64 | $value = (string)substr($line, $pos + (isset($line[$pos + 1]) && $line[$pos + 1] === ' ' ? 2 : 1)); 65 | $key = substr($line, 0, $pos); 66 | } 67 | 68 | if (isset($fields[$key])) { 69 | if (!is_array($fields[$key])) { 70 | $fields[$key] = array($fields[$key]); 71 | } 72 | $fields[$key][] = $value; 73 | } else { 74 | $fields[$key] = $value; 75 | } 76 | } 77 | 78 | reset($fields); 79 | $key = key($fields); 80 | 81 | if ($key === 'Event') { 82 | return new Event($fields); 83 | } 84 | 85 | return new Response($fields); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Protocol/Response.php: -------------------------------------------------------------------------------- 1 | fields = $fields; 17 | } 18 | 19 | /** 20 | * Get the resulting output of a "command" Action. 21 | * 22 | * This value is only available if this is actually a response to a "command" 23 | * action, otherwise it defaults to null. 24 | * 25 | * ```php$sender->command('help')->then(function (Response $response) { 26 | * echo $response->getCommandOutput(); 27 | * }); 28 | * ``` 29 | * 30 | * @return ?string 31 | */ 32 | public function getCommandOutput() 33 | { 34 | // legacy Asterisk uses custom format for command output 35 | $output = $this->getFieldValue(self::FIELD_COMMAND_OUTPUT); 36 | if ($output !== null) { 37 | return $output; 38 | } 39 | 40 | // Asterisk 14+ uses multiple "Output" fields: https://github.com/asterisk/asterisk/commit/2f418c052ec 41 | $output = $this->getFieldValues('Output'); 42 | if (!$output) { 43 | return null; 44 | } 45 | 46 | return implode("\n", $output); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Protocol/UnexpectedMessageException.php: -------------------------------------------------------------------------------- 1 | getActionId() . '" received'); 14 | $this->response = $response; 15 | } 16 | 17 | public function getResponse() 18 | { 19 | return $this->response; 20 | } 21 | } 22 | --------------------------------------------------------------------------------