├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── composer.json ├── phpunit.xml └── src ├── Client.php ├── Encoder └── GsmEncoder.php ├── Exception └── SmppException.php ├── Helper.php ├── Logger.php ├── Pdu ├── DeliverReceiptSm.php ├── DeliverSm.php ├── Part │ ├── Address.php │ ├── Tag.php │ ├── TagUssdServiceOp.php │ └── TagUssdSessionId.php ├── Pdu.php ├── Sm.php ├── SmppDeliveryReceipt.php ├── SmppSms.php ├── SubmitSm.php └── Ussd.php ├── PduParser.php ├── SMPP.php ├── Service ├── Listener.php ├── Sender.php └── Service.php ├── Tests ├── ReadSMSTest.php ├── ReadUSSDTest.php ├── SendSMSTest.php ├── data │ ├── deliver_receipt.hex │ ├── deliver_sm_enquire_link.hex │ ├── deliver_sm_ussd.hex │ └── ussd.hex ├── parsedeliversm.php ├── readsm.php └── sendsm.php └── Transport ├── Exception └── SocketTransportException.php ├── FakeTransport.php ├── SMPPSocketTransport.php ├── SocketTransport.php └── TransportInterface.php /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | composer.lock 3 | .idea 4 | .phpunit* -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | sudo: false 4 | 5 | php: 6 | - 7.1 7 | - 7.2 8 | - 7.3 9 | - 7.4 10 | 11 | before_script: 12 | - composer config discard-changes true 13 | - travis_retry composer install --no-interaction 14 | 15 | script: 16 | - ./vendor/bin/phpunit --verbose 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 glushkovds 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 10 | furnished 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | PHP implementation SMPP v3.4 protocol 2 | ============= 3 | 4 | [![Build Status](https://travis-ci.org/glushkovds/php-smpp.svg?branch=master)](https://travis-ci.org/glushkovds/php-smpp) 5 | [![Latest Stable Version](https://poser.pugx.org/glushkovds/php-smpp/v/stable)](https://packagist.org/packages/glushkovds/php-smpp) 6 | [![Total Downloads](https://poser.pugx.org/glushkovds/php-smpp/downloads)](https://packagist.org/packages/glushkovds/php-smpp) 7 | [![Latest Unstable Version](https://poser.pugx.org/glushkovds/php-smpp/v/unstable)](https://packagist.org/packages/glushkovds/php-smpp) 8 | [![License](https://poser.pugx.org/glushkovds/php-smpp/license)](https://packagist.org/packages/glushkovds/php-smpp) 9 | 10 | Allows send and read SMS and USSD. 11 | 12 | This is a simplified SMPP client lib for sending or receiving smses through [SMPP v3.4](http://www.smsforum.net/SMPP_v3_4_Issue1_2.zip). 13 | 14 | In addition to the client, this lib also contains an encoder for converting UTF-8 text to the GSM 03.38 encoding, and a socket wrapper. The socket wrapper provides connection pool, IPv6 and timeout monitoring features on top of PHP's socket extension. 15 | 16 | This lib has changed significantly from it's parent. 17 | 18 | This lib requires the [sockets](http://www.php.net/manual/en/book.sockets.php) PHP-extension, and is not supported on Windows. 19 | 20 | Inheritance 21 | ----- 22 | 23 | This implementation based on [php-smpp library](https://github.com/agladkov/php-smpp) 24 | 25 | Key differences: 26 | 1. Send and listen USSD messages 27 | 1. Object oriented way with Pdu, ShortMessage, Sms and other classes 28 | 1. PSR-1,4,12 support 29 | 1. Requires php7.4+ 30 | 1. Phpunit auto tests 31 | 32 | Installation 33 | ----- 34 | 35 | ```bash 36 | composer require glushkovds/php-smpp "^0.5" 37 | ``` 38 | 39 | Basic usage example 40 | ----- 41 | 42 | To send a SMS you can do: 43 | 44 | ```php 45 | send(79001001010, 'Hello world!', 'Sender'); 50 | ``` 51 | 52 | To receive a SMS (or delivery receipt): 53 | 54 | ```php 55 | listen(function (\PhpSmpp\Pdu\Sm $sm) { 60 | var_dump($sm->msgId); 61 | if ($sm instanceof \PhpSmpp\Pdu\DeliverReceiptSm) { 62 | var_dump($sm->state); 63 | var_dump($sm->state == \PhpSmpp\SMPP::STATE_DELIVERED); 64 | // do some job with delivery receipt 65 | } else { 66 | echo 'not receipt'; 67 | } 68 | }); 69 | ``` 70 | 71 | To send a USSD you can do: 72 | 73 | ```php 74 | sendUSSD(79001001010, 'Hello world!', 'Sender', []); 79 | ``` 80 | 81 | To receive a USSD: 82 | 83 | ```php 84 | require_once 'vendor/autoload.php'; 85 | 86 | $service = new \PhpSmpp\Service\Listener(['smschost.net'], 'login', 'pass'); 87 | $service->listen(function (\PhpSmpp\Pdu\Pdu $pdu) { 88 | var_dump($pdu->id); 89 | var_dump($pdu->sequence); 90 | if ($pdu instanceof \PhpSmpp\Pdu\Ussd) { 91 | var_dump($pdu->status); 92 | var_dump($pdu->source->value); 93 | var_dump($pdu->destination->value); 94 | var_dump($pdu->message); 95 | // do some job with ussd 96 | } 97 | }); 98 | ``` 99 | 100 | Perform testing your code with fake transport (also available for Listener): 101 | 102 | ```php 103 | 104 | client->setTransport(new \PhpSmpp\Transport\FakeTransport()); 109 | $smsId = $service->send(79001001010, 'Hello world!', 'Sender'); 110 | ``` 111 | 112 | 113 | Connection pools 114 | ----- 115 | You can specify a list of connections to have the SocketTransport attempt each one in succession or randomly. Also if you give it a hostname with multiple A/AAAA-records it will try each one. 116 | If you want to monitor the DNS lookups, set defaultDebug to true before constructing the transport. 117 | 118 | The (configurable) send timeout governs how long it will wait for each server to timeout. It can take a long time to try a long list of servers, depending on the timeout. You can change the timeout both before and after the connection attempts are made. 119 | 120 | The transport supports IPv6 and will prefer IPv6 addresses over IPv4 when available. You can modify this feature by setting forceIpv6 or forceIpv4 to force it to only use IPv6 or IPv4. 121 | 122 | In addition to the DNS lookups, it will also look for local IPv4 addresses using gethostbyname(), so "localhost" works for IPv4. For IPv6 localhost specify "::1". 123 | 124 | 125 | Implementation notes 126 | ----- 127 | 128 | - The SUBMIT_MULTI operation of SMPP, which sends a SMS to a list of recipients, is not supported atm. You can easily add it though. 129 | - The sockets will return false if the timeout is reached on read() (but not readAll or write). 130 | You can use this feature to implement an enquire_link policy. If you need to send enquire_link for every 30 seconds of inactivity, 131 | set a timeout of 30 seconds, and send the enquire_link command after readSMS() returns false. 132 | - The examples above assume that the SMSC default datacoding is [GSM 03.38](http://en.wikipedia.org/wiki/GSM_03.38). 133 | - Remember to activate registered delivery if you want delivery receipts (set to SMPP::REG_DELIVERY_SMSC_BOTH / 0x01). 134 | - Both the SmppClient and transport components support a debug callback, which defaults to [error_log](http://www.php.net/manual/en/function.error-log.php) . Use this to redirect debug information. 135 | 136 | F.A.Q. 137 | ----- 138 | 139 | **I can't send more than 160 chars** 140 | There are three built-in methods to send Concatenated SMS (csms); CSMS_16BIT_TAGS, CSMS_PAYLOAD, CSMS_8BIT_UDH. CSMS_16BIT_TAGS is the default, if it don't work try another. 141 | 142 | **Can it run on windows?** 143 | Maybe! I think this is no good. But you can try it or even contribute windows supporting feature. 144 | 145 | **Why am I not seeing any debug output?** 146 | Remember to implement a debug callback for SocketTransport and SmppClient to use. Otherwise they default to [error_log](http://www.php.net/manual/en/function.error-log.php) which may or may not print to screen. 147 | 148 | **Why do I get 'res_nsend() failed' or 'Could not connect to any of the specified hosts' errors?** 149 | Your provider's DNS server probably has an issue with IPv6 addresses (AAAA records). Try to set ```SocketTransport::$forceIpv4=true;```. You can also try specifying an IP-address (or a list of IPs) instead. Setting ```SocketTransport:$defaultDebug=true;``` before constructing the transport is also useful in resolving connection issues. 150 | 151 | **I tried forcing IPv4 and/or specifying an IP-address, but I'm still getting 'Could not connect to any of the specified hosts'?** 152 | It would be a firewall issue that's preventing your connection, or something else entirely. Make sure debug output is enabled and displayed. If you see something like 'Socket connect to 1.2.3.4:2775 failed; Operation timed out' this means a connection could not be etablished. If this isn't a firewall issue, you might try increasing the connect timeout. The sendTimeout also specifies the connect timeout, call ```$transport->setSendTimeout(10000);``` to set a 10-second timeout. 153 | 154 | **Why do I get 'Failed to read reply to command: 0x4', 'Message Length is invalid' or 'Error in optional part' errors?** 155 | Most likely your SMPP provider doesn't support NULL-terminating the message field. The specs aren't clear on this issue, so there is a toggle. Set ```SmppClient::$sms_null_terminate_octetstrings = false;``` and try again. 156 | 157 | **What does 'Bind Failed' mean?** 158 | It typically means your SMPP provider rejected your login credentials, ie. your username or password. 159 | 160 | **Can I test the client library without a SMPP server?** 161 | Yes, but not full functionality, by FakeTransport class. 162 | Also you can try simulators from official SMPP website: https://smpp.org/smpp-testing-development.html 163 | 164 | **I have an issue that not mentioned here, what do I do?** 165 | Please obtain full debug information, and open an issue here on github. Make sure not to include the Send PDU hex-codes of the BindTransmitter call, since it will contain your username and password. Other hex-output is fine, and greatly appeciated. Any PHP Warnings or Notices could also be important. Please include information about what SMPP server you are connecting to, and any specifics. 166 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "glushkovds/php-smpp", 3 | "description": "Implementation SMPP v3.4 protocol. Includes sending and listening USSD.", 4 | "keywords": [ 5 | "smpp", 6 | "php", 7 | "lib" 8 | ], 9 | "homepage": "https://github.com/glushkovds/php-smpp", 10 | "type": "library", 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "Denis Glushkov", 15 | "email": "amkarovec@gmail.com", 16 | "homepage": "https://github.com/glushkovds" 17 | } 18 | ], 19 | "require": { 20 | "php": ">=7.4.0", 21 | "ext-sockets": "*" 22 | }, 23 | "require-dev": { 24 | "phpunit/phpunit": ">7.0" 25 | }, 26 | "suggest": { 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "PhpSmpp\\": "src/" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | src/Tests 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/Client.php: -------------------------------------------------------------------------------- 1 | hosts = $hosts; 114 | 115 | // Internal parameters 116 | $this->sequence_number = 1; 117 | $this->debug = false; 118 | $this->pdu_queue = []; 119 | 120 | $this->debugHandler = 'error_log'; 121 | $this->mode = null; 122 | } 123 | 124 | public function setDebugHandler(callable $callback) 125 | { 126 | $this->debugHandler = $callback; 127 | if ($this->transport) { 128 | $this->setDebugHandler($callback); 129 | } 130 | } 131 | 132 | /** 133 | * @return TransportInterface 134 | */ 135 | public function getTransport() 136 | { 137 | if (empty($this->transport)) { 138 | $hosts = $ports = []; 139 | foreach ($this->hosts as $host) { 140 | $ar = explode(':', $host); 141 | $hosts[] = $ar[0]; 142 | $ports[] = $ar[1] ?? static::DEFAULT_PORT; 143 | } 144 | $this->transport = new SMPPSocketTransport($hosts, $ports, false, $this->debugHandler); 145 | $this->transport->setRecvTimeout(10000); // 10 seconds 146 | } 147 | return $this->transport; 148 | } 149 | 150 | public function setTransport(TransportInterface $transport) 151 | { 152 | $this->transport = $transport; 153 | } 154 | 155 | /** 156 | * Binds the receiver. 157 | * @param string $login - ESME system_id 158 | * @param string $pass - ESME password 159 | * @return bool 160 | * @throws SmppException 161 | */ 162 | public function bindReceiver($login, $pass) 163 | { 164 | $this->mode = static::BIND_MODE_RECEIVER; 165 | $this->bind($login, $pass, SMPP::BIND_RECEIVER); 166 | return true; 167 | } 168 | 169 | /** 170 | * Binds the transmitter. 171 | * @param string $login - ESME system_id 172 | * @param string $pass - ESME password 173 | * @return bool 174 | * @throws SmppException 175 | */ 176 | public function bindTransmitter($login, $pass) 177 | { 178 | $this->mode = static::BIND_MODE_TRANSMITTER; 179 | $this->bind($login, $pass, SMPP::BIND_TRANSMITTER); 180 | return true; 181 | } 182 | 183 | /** 184 | * Binds the transceiver. 185 | * @param string $login - ESME system_id 186 | * @param string $pass - ESME password 187 | * @return bool 188 | * @throws SmppException 189 | */ 190 | public function bindTransceiver($login, $pass) 191 | { 192 | $this->mode = static::BIND_MODE_TRANSCEIVER; 193 | $this->bind($login, $pass, SMPP::BIND_TRANSCEIVER); 194 | return true; 195 | } 196 | 197 | /** 198 | * Set callback handler for Sm, when it arrived 199 | * @param Callable $callback param is Sm object 200 | */ 201 | public function setListener(callable $callback) 202 | { 203 | $this->readSmsCallback = $callback; 204 | } 205 | 206 | /** 207 | * Closes the session on the SMSC server. 208 | */ 209 | public function close() 210 | { 211 | $this->sequence_number = 1; 212 | $this->pdu_queue = []; 213 | 214 | if (!$this->transport->isOpen()) { 215 | return; 216 | } 217 | if ($this->debug) { 218 | call_user_func($this->debugHandler, 'Unbinding...'); 219 | } 220 | 221 | try { 222 | $response = $this->sendCommand(SMPP::UNBIND, ""); 223 | if ($this->debug) { 224 | call_user_func($this->debugHandler, "Unbind status: " . $response->status); 225 | } 226 | } catch (\Throwable $e) { 227 | if ($this->debug) { 228 | call_user_func($this->debugHandler, "Unbind status: thrown error: " . $e->getMessage()); 229 | } 230 | // Do nothing 231 | } 232 | 233 | $this->transport->close(); 234 | } 235 | 236 | /** 237 | * Parse a timestring as formatted by SMPP v3.4 section 7.1. 238 | * Returns an unix timestamp if $newDates is false or DateTime/DateInterval is missing, 239 | * otherwise an object of either DateTime or DateInterval is returned. 240 | * 241 | * @param string $input 242 | * @param boolean $newDates 243 | * @return mixed 244 | * @throws \Exception 245 | */ 246 | public function parseSmppTime($input, $newDates = true) 247 | { 248 | // Check for support for new date classes 249 | if (!class_exists('DateTime') || !class_exists('DateInterval')) { 250 | $newDates = false; 251 | } 252 | 253 | $numMatch = preg_match('/^(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{1})(\\d{2})([R+-])$/', $input, $matches); 254 | if (!$numMatch) { 255 | return null; 256 | } 257 | list($whole, $y, $m, $d, $h, $i, $s, $t, $n, $p) = $matches; 258 | 259 | // Use strtotime to convert relative time into a unix timestamp 260 | if ($p == 'R') { 261 | if ($newDates) { 262 | $spec = "P"; 263 | if ($y) $spec .= $y . 'Y'; 264 | if ($m) $spec .= $m . 'M'; 265 | if ($d) $spec .= $d . 'D'; 266 | if ($h || $i || $s) $spec .= 'T'; 267 | if ($h) $spec .= $h . 'H'; 268 | if ($i) $spec .= $i . 'M'; 269 | if ($s) $spec .= $s . 'S'; 270 | return new \DateInterval($spec); 271 | } else { 272 | return strtotime("+$y year +$m month +$d day +$h hour +$i minute $s +second"); 273 | } 274 | } else { 275 | $offsetHours = floor($n / 4); 276 | $offsetMinutes = ($n % 4) * 15; 277 | $time = sprintf("20%02s-%02s-%02sT%02s:%02s:%02s%s%02s:%02s", $y, $m, $d, $h, $i, $s, $p, $offsetHours, $offsetMinutes); // Not Y3K safe 278 | if ($newDates) { 279 | return new \DateTime($time); 280 | } else { 281 | return strtotime($time); 282 | } 283 | } 284 | } 285 | 286 | /** 287 | * Query the SMSC about current state/status of a previous sent SMS. 288 | * You must specify the SMSC assigned message id and source of the sent SMS. 289 | * Returns an associative array with elements: message_id, final_date, message_state and error_code. 290 | * message_state would be one of the SMPP::STATE_* constants. (SMPP v3.4 section 5.2.28) 291 | * error_code depends on the telco network, so could be anything. 292 | * 293 | * @param string $messageid 294 | * @param Address $source 295 | * @return array 296 | * @throws \Exception 297 | */ 298 | public function queryStatus($messageid, Address $source) 299 | { 300 | $pduBody = pack( 301 | 'a' . (strlen($messageid) + 1) . 'cca' . (strlen($source->value) + 1), 302 | $messageid, $source->ton, $source->npi, $source->value 303 | ); 304 | $reply = $this->sendCommand(SMPP::QUERY_SM, $pduBody); 305 | if (!$reply || $reply->status != SMPP::ESME_ROK) { 306 | return null; 307 | } 308 | 309 | // Parse reply 310 | $posId = strpos($reply->body, "\0", 0); 311 | $posDate = strpos($reply->body, "\0", $posId + 1); 312 | $data = []; 313 | $data['message_id'] = substr($reply->body, 0, $posId); 314 | $data['final_date'] = substr($reply->body, $posId, $posDate - $posId); 315 | $data['final_date'] = $data['final_date'] ? $this->parseSmppTime(trim($data['final_date'])) : null; 316 | $status = unpack("cmessage_state/cerror_code", substr($reply->body, $posDate + 1)); 317 | return array_merge($data, $status); 318 | } 319 | 320 | protected function handleNonSmPdu(Pdu $pdu) 321 | { 322 | if ($pdu->id == SMPP::ENQUIRE_LINK) { 323 | $response = new Pdu(SMPP::ENQUIRE_LINK_RESP, SMPP::ESME_ROK, $pdu->sequence, "\x00"); 324 | $this->sendPDU($response); 325 | } 326 | } 327 | 328 | public function listenSm(callable $callback) 329 | { 330 | do { 331 | $pdu = $this->readPDU(); 332 | if ($pdu === false) { 333 | return; 334 | } 335 | if (PduParser::isSm($pdu)) { 336 | try { 337 | $sm = PduParser::fromPdu($pdu); 338 | $this->sendPDU($sm->buildResp()); 339 | } catch (\Throwable $e) { 340 | call_user_func($this->debugHandler, 'Failed parse pdu: ' . $e->getMessage() . ' ' . print_r($pdu, true)); 341 | usleep(10e4); 342 | continue; 343 | } 344 | $callback($sm); 345 | continue; 346 | } 347 | $this->handleNonSmPdu($pdu); 348 | } while ($pdu); 349 | } 350 | 351 | /** 352 | * Send one SMS to SMSC. Can be executed only after bindTransmitter() call. 353 | * $message is always in octets regardless of the data encoding. 354 | * For correct handling of Concatenated SMS, message must be encoded with GSM 03.38 (data_coding 0x00) or UCS-2BE (0x08). 355 | * Concatenated SMS'es uses 16-bit reference numbers, which gives 152 GSM 03.38 chars or 66 UCS-2BE chars per CSMS. 356 | * 357 | * @param Address $from 358 | * @param Address $to 359 | * @param string $message 360 | * @param array $tags (optional) 361 | * @param integer $dataCoding (optional) 362 | * @param integer $priority (optional) 363 | * @param string $scheduleDeliveryTime (optional) 364 | * @param string $validityPeriod (optional) 365 | * @return string message id 366 | */ 367 | public function sendSMS( 368 | Address $from, 369 | Address $to, 370 | $message, 371 | $tags = null, 372 | $dataCoding = SMPP::DATA_CODING_DEFAULT, 373 | $priority = 0x00, 374 | $scheduleDeliveryTime = null, 375 | $validityPeriod = '000000100000000R' 376 | ) 377 | { 378 | $msg_length = strlen($message); 379 | 380 | if ($msg_length > 160 && $dataCoding != SMPP::DATA_CODING_UCS2 && $dataCoding != SMPP::DATA_CODING_DEFAULT) { 381 | return false; 382 | } 383 | 384 | switch ($dataCoding) { 385 | case SMPP::DATA_CODING_UCS2: 386 | $singleSmsOctetLimit = 140; // in octets, 70 UCS-2 chars 387 | $csmsSplit = 132; // There are 133 octets available, but this would split the UCS the middle so use 132 instead 388 | break; 389 | case SMPP::DATA_CODING_DEFAULT: 390 | $singleSmsOctetLimit = 160; // we send data in octets, but GSM 03.38 will be packed in septets (7-bit) by SMSC. 391 | $csmsSplit = 152; // send 152 chars in each SMS since, we will use 16-bit CSMS ids (SMSC will format data) 392 | break; 393 | default: 394 | $singleSmsOctetLimit = 254; // From SMPP standard 395 | break; 396 | } 397 | 398 | // Figure out if we need to do CSMS, since it will affect our PDU 399 | if ($msg_length > $singleSmsOctetLimit) { 400 | $doCsms = true; 401 | if ($this->csmsMethod != Client::CSMS_PAYLOAD) { 402 | $parts = $this->splitMessageString($message, $csmsSplit, $dataCoding); 403 | $short_message = reset($parts); 404 | $csmsReference = $this->getCsmsReference(); 405 | } 406 | } else { 407 | $short_message = $message; 408 | $doCsms = false; 409 | } 410 | 411 | // Deal with CSMS 412 | if ($doCsms) { 413 | if ($this->csmsMethod == Client::CSMS_PAYLOAD) { 414 | $payload = new Tag(Tag::MESSAGE_PAYLOAD, $message, $msg_length); 415 | return $this->submit_sm( 416 | $from, $to, null, (empty($tags) ? array($payload) : array_merge($tags, $payload)), 417 | $dataCoding, $priority, $scheduleDeliveryTime, $validityPeriod 418 | ); 419 | } elseif ($this->csmsMethod == Client::CSMS_8BIT_UDH) { 420 | $seqnum = 1; 421 | $messageIds = []; 422 | foreach ($parts as $part) { 423 | $udh = pack('cccccc', 5, 0, 3, substr($csmsReference, 1, 1), count($parts), $seqnum); 424 | $messageId = $this->submit_sm( 425 | $from, $to, $udh . $part, $tags, $dataCoding, $priority, 426 | $scheduleDeliveryTime, $validityPeriod, (Client::$sms_esm_class | 0x40), 427 | true 428 | ); 429 | if ($messageId) { 430 | $messageIds[] = $messageId; 431 | } 432 | $seqnum++; 433 | } 434 | return !empty($messageIds) ? $messageIds : false; 435 | } else { 436 | $sar_msg_ref_num = new Tag(Tag::SAR_MSG_REF_NUM, $csmsReference, 2, 'n'); 437 | $sar_total_segments = new Tag(Tag::SAR_TOTAL_SEGMENTS, count($parts), 1, 'c'); 438 | $seqnum = 1; 439 | $messageIds = []; 440 | foreach ($parts as $part) { 441 | $sartags = [$sar_msg_ref_num, $sar_total_segments, new Tag(Tag::SAR_SEGMENT_SEQNUM, $seqnum, 1, 'c')]; 442 | $messageId = $this->submit_sm( 443 | $from, $to, $part, (empty($tags) ? $sartags : array_merge($tags, $sartags)), 444 | $dataCoding, $priority, $scheduleDeliveryTime, $validityPeriod, 445 | null, true 446 | ); 447 | if ($messageId) { 448 | $messageIds[] = $messageId; 449 | } 450 | $seqnum++; 451 | } 452 | return !empty($messageIds) ? $messageIds : false; 453 | } 454 | } 455 | 456 | return $this->submit_sm($from, $to, $short_message, $tags, $dataCoding); 457 | } 458 | 459 | /** 460 | * Perform the actual submit_sm call to send SMS. 461 | * Implemented as a protected method to allow automatic sms concatenation. 462 | * Tags must be an array of already packed and encoded TLV-params. 463 | * 464 | * @param Address $source 465 | * @param Address $destination 466 | * @param string $short_message 467 | * @param array $tags 468 | * @param integer $dataCoding 469 | * @param integer $priority 470 | * @param string $scheduleDeliveryTime 471 | * @param string $validityPeriod 472 | * @param string $esmClass 473 | * @return string message id 474 | */ 475 | protected function submit_sm( 476 | Address $source, 477 | Address $destination, 478 | $short_message = null, 479 | $tags = null, 480 | $dataCoding = SMPP::DATA_CODING_DEFAULT, 481 | $priority = 0x00, 482 | $scheduleDeliveryTime = null, 483 | $validityPeriod = null, 484 | $esmClass = null, 485 | $needReply = true 486 | ) 487 | { 488 | if (is_null($esmClass)) { 489 | $esmClass = self::$sms_esm_class; 490 | } 491 | 492 | // Construct PDU with mandatory fields 493 | $pdu = pack( 494 | 'a1cca' . (strlen($source->value) + 1) 495 | . 'cca' . (strlen($destination->value) + 1) 496 | . 'ccc' . ($scheduleDeliveryTime ? 'a16x' : 'a1') . ($validityPeriod ? 'a16x' : 'a1') 497 | . 'ccccca' . (strlen($short_message) + ($this->nullTerminateOctetstrings ? 1 : 0)), 498 | self::$sms_service_type, 499 | $source->ton, 500 | $source->npi, 501 | $source->value, 502 | $destination->ton, 503 | $destination->npi, 504 | $destination->value, 505 | $esmClass, 506 | self::$sms_protocol_id, 507 | $priority, 508 | $scheduleDeliveryTime, 509 | $validityPeriod, 510 | self::$sms_registered_delivery_flag, 511 | self::$sms_replace_if_present_flag, 512 | $dataCoding, 513 | self::$sms_sm_default_msg_id, 514 | strlen($short_message) + ($this->nullTerminateOctetstrings ? 1 : 0),//sm_length 515 | $short_message//short_message 516 | ); 517 | 518 | // Add any tags 519 | if (!empty($tags)) { 520 | foreach ($tags as $tag) { 521 | $pdu .= $tag->getBinary(); 522 | } 523 | } 524 | 525 | $response = $this->sendCommand(SMPP::SUBMIT_SM, $pdu, $needReply); 526 | 527 | if ($needReply && $response) { 528 | if ($response->status == SMPP::ESME_ROK) { 529 | $body = unpack("a*msgid", $response->body); 530 | return $body['msgid']; 531 | } else { 532 | throw new SmppException(SMPP::getStatusMessage($response->status), $response->status); 533 | } 534 | } 535 | return null; 536 | } 537 | 538 | /** 539 | * Get a CSMS reference number for sar_msg_ref_num. 540 | * Initializes with a random value, and then returns the number in sequence with each call. 541 | */ 542 | protected function getCsmsReference() 543 | { 544 | $limit = ($this->csmsMethod == static::CSMS_8BIT_UDH) ? 255 : 65535; 545 | if (!isset($this->sar_msg_ref_num)) { 546 | $this->sar_msg_ref_num = mt_rand(0, $limit); 547 | } 548 | $this->sar_msg_ref_num++; 549 | if ($this->sar_msg_ref_num > $limit) { 550 | $this->sar_msg_ref_num = 0; 551 | } 552 | return $this->sar_msg_ref_num; 553 | } 554 | 555 | 556 | /** 557 | * Split a message into multiple parts, taking the encoding into account. 558 | * A character represented by an GSM 03.38 escape-sequence shall not be split in the middle. 559 | * Uses str_split if at all possible, and will examine all split points for escape chars if it's required. 560 | * 561 | * @param string $message 562 | * @param integer $split 563 | * @param integer $dataCoding (optional) 564 | * @return array 565 | */ 566 | protected function splitMessageString($message, $split, $dataCoding = SMPP::DATA_CODING_DEFAULT) 567 | { 568 | switch ($dataCoding) { 569 | case SMPP::DATA_CODING_DEFAULT: 570 | $msg_length = strlen($message); 571 | // Do we need to do php based split? 572 | $numParts = floor($msg_length / $split); 573 | if ($msg_length % $split == 0) $numParts--; 574 | $slowSplit = false; 575 | 576 | for ($i = 1; $i <= $numParts; $i++) { 577 | if ($message[$i * $split - 1] == "\x1B") { 578 | $slowSplit = true; 579 | break; 580 | }; 581 | } 582 | if (!$slowSplit) { 583 | return str_split($message, $split); 584 | } 585 | 586 | // Split the message char-by-char 587 | $parts = array(); 588 | $part = null; 589 | $n = 0; 590 | for ($i = 0; $i < $msg_length; $i++) { 591 | $c = $message[$i]; 592 | // reset on $split or if last char is a GSM 03.38 escape char 593 | if ($n == $split || ($n == ($split - 1) && $c == "\x1B")) { 594 | $parts[] = $part; 595 | $n = 0; 596 | $part = null; 597 | } 598 | $part .= $c; 599 | } 600 | $parts[] = $part; 601 | return $parts; 602 | // UCS2-BE can just use str_split since we send 132 octets per message, which gives a fine split using UCS2 603 | case SMPP::DATA_CODING_UCS2: 604 | default: 605 | return str_split($message, $split); 606 | } 607 | } 608 | 609 | /** 610 | * Binds the socket and opens the session on SMSC 611 | * @param string $login ESME system_id 612 | * @param string $pass ESME password 613 | * @param string $command_id 614 | * @return Pdu 615 | */ 616 | protected function bind($login, $pass, $command_id) 617 | { 618 | $this->login = null; 619 | $this->pass = null; 620 | 621 | if (!$this->transport->isOpen()) { 622 | return false; 623 | } 624 | if ($this->debug) { 625 | call_user_func($this->debugHandler, "Binding $this->mode..."); 626 | } 627 | 628 | // Make PDU body 629 | $pduBody = pack( 630 | 'a' . (strlen($login) + 1) . 631 | 'a' . (strlen($pass) + 1) . 632 | 'a' . (strlen(self::$system_type) + 1) . 633 | 'CCCa' . (strlen(self::$address_range) + 1), 634 | $login, $pass, self::$system_type, 635 | self::$interface_version, self::$addr_ton, 636 | self::$addr_npi, self::$address_range 637 | ); 638 | 639 | $response = $this->sendCommand($command_id, $pduBody); 640 | 641 | if ($this->debug) { 642 | call_user_func($this->debugHandler, "Binding status : " . $response->status); 643 | } 644 | 645 | if ($response->status != SMPP::ESME_ROK) { 646 | throw new SmppException(SMPP::getStatusMessage($response->status), $response->status); 647 | } 648 | 649 | $this->login = $login; 650 | $this->pass = $pass; 651 | return $response; 652 | } 653 | 654 | /** 655 | * Send the enquire link command. 656 | * @return Pdu 657 | */ 658 | public function enquireLink() 659 | { 660 | $response = $this->sendCommand(SMPP::ENQUIRE_LINK, null); 661 | return $response; 662 | } 663 | 664 | /** 665 | * Respond to any enquire link we might have waiting. 666 | * If will check the queue first and respond to any enquire links we have there. 667 | * Then it will move on to the transport, and if the first PDU is enquire link respond, 668 | * otherwise add it to the queue and return. 669 | * 670 | */ 671 | public function respondEnquireLink() 672 | { 673 | // Check the queue first 674 | $ql = count($this->pdu_queue); 675 | for ($i = 0; $i < $ql; $i++) { 676 | $pdu = $this->pdu_queue[$i]; 677 | if ($pdu->id == SMPP::ENQUIRE_LINK) { 678 | //remove response 679 | array_splice($this->pdu_queue, $i, 1); 680 | $this->sendPDU(new Pdu(SMPP::ENQUIRE_LINK_RESP, SMPP::ESME_ROK, $pdu->sequence, "\x00")); 681 | } 682 | } 683 | 684 | // Check the transport for data 685 | if ($this->transport->hasData()) { 686 | $pdu = $this->readPDU(); 687 | if ($pdu->id == SMPP::ENQUIRE_LINK) { 688 | $this->sendPDU(new Pdu(SMPP::ENQUIRE_LINK_RESP, SMPP::ESME_ROK, $pdu->sequence, "\x00")); 689 | } elseif ($pdu) { 690 | array_push($this->pdu_queue, $pdu); 691 | } 692 | } 693 | } 694 | 695 | /** 696 | * Reconnect to SMSC. 697 | * This is mostly to deal with the situation were we run out of sequence numbers 698 | */ 699 | protected function reconnect() 700 | { 701 | $this->close(); 702 | sleep(1); 703 | $this->transport->open(); 704 | $this->sequence_number = 1; 705 | 706 | if ($this->mode == 'receiver') { 707 | $this->bindReceiver($this->login, $this->pass); 708 | } else { 709 | $this->bindTransmitter($this->login, $this->pass); 710 | } 711 | } 712 | 713 | /** 714 | * Sends the PDU command to the SMSC and waits for response. 715 | * @param integer $id - command ID 716 | * @param string $pduBody - PDU body 717 | * @return Pdu 718 | */ 719 | protected function sendCommand($id, $pduBody, $needReply = true) 720 | { 721 | if (!$this->transport->isOpen()) { 722 | return false; 723 | } 724 | $pdu = new Pdu($id, 0, $this->sequence_number, $pduBody); 725 | 726 | $this->sendPDU($pdu); 727 | if ($needReply) { 728 | $response = $this->readPDU_resp($this->sequence_number, $pdu->id); 729 | if ($response === false) { 730 | throw new SmppException('Failed to read reply to command: 0x' . dechex($id)); 731 | } 732 | 733 | if ($response->status != SMPP::ESME_ROK) { 734 | throw new SmppException(SMPP::getStatusMessage($response->status), $response->status); 735 | } 736 | } 737 | 738 | $this->sequence_number++; 739 | 740 | // Reached max sequence number, spec does not state what happens now, so we re-connect 741 | if ($this->sequence_number >= 0x7FFFFFFF) { 742 | $this->reconnect(); 743 | } 744 | 745 | return $needReply ? $response : null; 746 | } 747 | public function sendDLRCommnd($pdu) 748 | { 749 | return $this->sendCommand(SMPP::DELIVER_SM, $pdu, true); 750 | } 751 | 752 | 753 | /** 754 | * Prepares and sends PDU to SMSC. 755 | * @param Pdu $pdu 756 | */ 757 | protected function sendPDU(Pdu $pdu) 758 | { 759 | if ($this->debug) { 760 | $length = strlen($pdu->body) + 16; 761 | $header = pack("NNNN", $length, $pdu->id, $pdu->status, $pdu->sequence); 762 | call_user_func( 763 | $this->debugHandler, 764 | "Send PDU: $length bytes;\ncommand_id: 0x" . dechex($pdu->id) . ";\n" 765 | . "sequence number: $pdu->sequence;\n" 766 | . ' ' . chunk_split(bin2hex($header . $pdu->body), 2, ' ') 767 | ); 768 | } 769 | $this->transport->sendPDU($pdu); 770 | } 771 | 772 | /** 773 | * Waits for SMSC response on specific PDU. 774 | * If a GENERIC_NACK with a matching sequence number, or null sequence is received instead it's also accepted. 775 | * Some SMPP servers, ie. logica returns GENERIC_NACK on errors. 776 | * 777 | * @param integer $seq_number - PDU sequence number 778 | * @param integer $command_id - PDU command ID 779 | * @return Pdu 780 | * @throws SmppException 781 | */ 782 | protected function readPDU_resp($seq_number, $command_id) 783 | { 784 | // Get response cmd id from command id 785 | $command_id = $command_id | SMPP::GENERIC_NACK; 786 | 787 | // Check the queue first 788 | $ql = count($this->pdu_queue); 789 | for ($i = 0; $i < $ql; $i++) { 790 | $pdu = $this->pdu_queue[$i]; 791 | if ( 792 | ($pdu->sequence == $seq_number && ($pdu->id == $command_id || $pdu->id == SMPP::GENERIC_NACK)) 793 | || ($pdu->sequence == null && $pdu->id == SMPP::GENERIC_NACK) 794 | ) { 795 | // remove response pdu from queue 796 | array_splice($this->pdu_queue, $i, 1); 797 | return $pdu; 798 | } 799 | } 800 | 801 | // Read PDUs until the one we are looking for shows up, or a generic nack pdu with matching sequence or null sequence 802 | do { 803 | $pdu = $this->readPDU(); 804 | if ($pdu) { 805 | if ($pdu->sequence == $seq_number && ($pdu->id == $command_id || $pdu->id == SMPP::GENERIC_NACK)) { 806 | return $pdu; 807 | } 808 | if ($pdu->sequence == null && $pdu->id == SMPP::GENERIC_NACK) { 809 | return $pdu; 810 | } 811 | array_push($this->pdu_queue, $pdu); // unknown PDU push to queue 812 | } 813 | } while ($pdu); 814 | return false; 815 | } 816 | 817 | protected function readPDU() 818 | { 819 | $bin = $this->transport->readPDU(); 820 | // Read PDU length 821 | $bufLength = substr($bin, 0, 4); 822 | if (!$bufLength) { 823 | return false; 824 | } 825 | extract(unpack("Nlength", $bufLength)); 826 | 827 | // Read PDU headers 828 | $bufHeaders = substr($bin, 4, 12); 829 | if (!$bufHeaders) { 830 | return false; 831 | } 832 | extract(unpack("Ncommand_id/Ncommand_status/Nsequence_number", $bufHeaders)); 833 | 834 | // Read PDU body 835 | if ($length - 16 > 0) { 836 | $body = substr($bin, 16); 837 | if (!$body) throw new \RuntimeException('Could not read PDU body'); 838 | } else { 839 | $body = null; 840 | } 841 | $pdu = new Pdu($command_id, $command_status, $sequence_number, $body); 842 | 843 | if ($this->debug) { 844 | call_user_func( 845 | $this->debugHandler, 846 | "Read PDU: $length bytes;\ncommand id: 0x" . dechex($command_id) . ";\n" 847 | . "command status: 0x" . dechex($command_status) . '; ' . SMPP::getStatusMessage($command_status) . ";\n" 848 | . "sequence number: $sequence_number;\n" 849 | . ' ' . chunk_split(bin2hex($bufLength . $bufHeaders . $body), 2, ' ') 850 | ); 851 | } 852 | 853 | return $pdu; 854 | } 855 | 856 | /** 857 | * Reads C style null padded string from the char array. 858 | * Reads until $maxlen or null byte. 859 | * 860 | * @param array $ar - input array 861 | * @param integer $maxlen - maximum length to read. 862 | * @param boolean $firstRead - is this the first bytes read from array? 863 | * @return read string. 864 | */ 865 | protected function getString(&$ar, $maxlen = 255, $firstRead = false) 866 | { 867 | $s = ""; 868 | $i = 0; 869 | do { 870 | $c = ($firstRead && $i == 0) ? current($ar) : next($ar); 871 | if ($c != 0) { 872 | $s .= chr($c); 873 | } 874 | $i++; 875 | } while ($i < $maxlen && $c != 0); 876 | return $s; 877 | } 878 | 879 | /** 880 | * Read a specific number of octets from the char array. 881 | * Does not stop at null byte 882 | * 883 | * @param array $ar - input array 884 | * @param intger $length 885 | * @return string 886 | */ 887 | protected function getOctets(&$ar, $length) 888 | { 889 | $s = ""; 890 | for ($i = 0; $i < $length; $i++) { 891 | $c = next($ar); 892 | if ($c === false) { 893 | return $s; 894 | } 895 | $s .= chr($c); 896 | } 897 | return $s; 898 | } 899 | 900 | /** 901 | * @param $ar 902 | * @return bool|Tag 903 | */ 904 | protected function parseTag(&$ar) 905 | { 906 | $unpackedData = unpack('nid/nlength', pack("C2C2", next($ar), next($ar), next($ar), next($ar))); 907 | if (!$unpackedData) throw new \InvalidArgumentException('Could not read tag data'); 908 | extract($unpackedData); 909 | 910 | // Sometimes SMSC return an extra null byte at the end 911 | if ($length == 0 && $id == 0) { 912 | return false; 913 | } 914 | 915 | $value = $this->getOctets($ar, $length); 916 | $tag = new Tag($id, $value, $length); 917 | if ($this->debug) { 918 | call_user_func( 919 | $this->debugHandler, 920 | "Parsed tag: id: 0x" . dechex($tag->id) . ";\n" 921 | . "length: $tag->length;\n" 922 | . 'value: ' . chunk_split(bin2hex($tag->value), 2, ' ') 923 | ); 924 | } 925 | return $tag; 926 | } 927 | 928 | /** 929 | * Extended version of submit_sm. 930 | * Allows to send USSD 931 | * 932 | * @param string $serviceType 933 | * @param Address $source 934 | * @param Address $destination 935 | * @param string $short_message 936 | * @param Tag[] $tags 937 | * @param integer $dataCoding 938 | * @param integer $priority 939 | * @param string $scheduleDeliveryTime 940 | * @param string $validityPeriod 941 | * @param string $esmClass 942 | * @return string message id 943 | */ 944 | protected function submit_sm_ex( 945 | $serviceType, 946 | Address $source, 947 | Address $destination, 948 | $short_message = null, 949 | $tags = null, 950 | $dataCoding = SMPP::DATA_CODING_DEFAULT, 951 | $priority = 0x00, 952 | $scheduleDeliveryTime = null, 953 | $validityPeriod = null, 954 | $esmClass = null 955 | ) 956 | { 957 | if (is_null($esmClass)) { 958 | $esmClass = self::$sms_esm_class; 959 | } 960 | $serviceTypeLen = strlen($serviceType) + 1; 961 | // Construct PDU with mandatory fields 962 | $pdu = pack( 963 | "a{$serviceTypeLen}cca" . (strlen($source->value) + 1) 964 | . 'cca' . (strlen($destination->value) + 1) 965 | . 'ccc' . ($scheduleDeliveryTime ? 'a16x' : 'a1') . ($validityPeriod ? 'a16x' : 'a1') 966 | . 'ccccca' . (strlen($short_message) + ($this->nullTerminateOctetstrings ? 1 : 0)), 967 | $serviceType, 968 | $source->ton, 969 | $source->npi, 970 | $source->value, 971 | $destination->ton, 972 | $destination->npi, 973 | $destination->value, 974 | $esmClass, 975 | self::$sms_protocol_id, 976 | $priority, 977 | $scheduleDeliveryTime, 978 | $validityPeriod, 979 | self::$sms_registered_delivery_flag, 980 | self::$sms_replace_if_present_flag, 981 | $dataCoding, 982 | self::$sms_sm_default_msg_id, 983 | strlen($short_message) + ($this->nullTerminateOctetstrings ? 1 : 0),//sm_length 984 | $short_message//short_message 985 | ); 986 | 987 | // Add any tags 988 | if (!empty($tags)) { 989 | foreach ($tags as $tag) { 990 | $pdu .= $tag->getBinary(); 991 | } 992 | } 993 | if ($this->debug) { 994 | $savepdu = chunk_split(bin2hex($pdu), 2, " "); 995 | call_user_func($this->debugHandler, "PDU hex: $savepdu"); 996 | } 997 | $response = $this->sendCommand(SMPP::SUBMIT_SM, $pdu); 998 | $body = unpack("a*msgid", $response->body); 999 | return $body['msgid']; 1000 | } 1001 | 1002 | public function sendUSSD(Address $from, Address $to, $message, $tags, $dataCoding) 1003 | { 1004 | return $this->submit_sm_ex( 1005 | SMPP::SERVICE_TYPE_USSD, $from, $to, $message, $tags, $dataCoding, 1006 | 0x00, null, null, SMPP::ESM_SUBMIT_MODE_STOREANDFORWARD 1007 | ); 1008 | } 1009 | } 1010 | -------------------------------------------------------------------------------- /src/Encoder/GsmEncoder.php: -------------------------------------------------------------------------------- 1 | "\x00", '£' => "\x01", '$' => "\x02", '¥' => "\x03", 'è' => "\x04", 'é' => "\x05", 'ù' => "\x06", 'ì' => "\x07", 'ò' => "\x08", 'Ç' => "\x09", 'Ø' => "\x0B", 'ø' => "\x0C", 'Å' => "\x0E", 'å' => "\x0F", 31 | 'Δ' => "\x10", '_' => "\x11", 'Φ' => "\x12", 'Γ' => "\x13", 'Λ' => "\x14", 'Ω' => "\x15", 'Π' => "\x16", 'Ψ' => "\x17", 'Σ' => "\x18", 'Θ' => "\x19", 'Ξ' => "\x1A", 'Æ' => "\x1C", 'æ' => "\x1D", 'ß' => "\x1E", 'É' => "\x1F", 32 | // all \x2? removed 33 | // all \x3? removed 34 | '¡' => "\x40", 35 | 'Ä' => "\x5B", 'Ö' => "\x5C", 'Ñ' => "\x5D", 'Ü' => "\x5E", '§' => "\x5F", 36 | '¿' => "\x60", 37 | 'ä' => "\x7B", 'ö' => "\x7C", 'ñ' => "\x7D", 'ü' => "\x7E", 'à' => "\x7F", 38 | '^' => "\x1B\x14", '{' => "\x1B\x28", '}' => "\x1B\x29", '\\' => "\x1B\x2F", '[' => "\x1B\x3C", '~' => "\x1B\x3D", ']' => "\x1B\x3E", '|' => "\x1B\x40", '€' => "\x1B\x65" 39 | ); 40 | $converted = strtr($string, $dict); 41 | 42 | // Replace unconverted UTF-8 chars from codepages U+0080-U+07FF, U+0080-U+FFFF and U+010000-U+10FFFF with a single ? 43 | return preg_replace('/([\\xC0-\\xDF].)|([\\xE0-\\xEF]..)|([\\xF0-\\xFF]...)/m','?',$converted); 44 | } 45 | 46 | /** 47 | * Count the number of GSM 03.38 chars a conversion would contain. 48 | * It's about 3 times faster to count than convert and do strlen() if conversion is not required. 49 | * 50 | * @param string $utf8String 51 | * @return integer 52 | */ 53 | public static function countGsm0338Length($utf8String) 54 | { 55 | $len = mb_strlen($utf8String,'utf-8'); 56 | $len += preg_match_all('/[\\^{}\\\~€|\\[\\]]/mu',$utf8String,$m); 57 | return $len; 58 | } 59 | 60 | /** 61 | * Pack an 8-bit string into 7-bit GSM format 62 | * Returns the packed string in binary format 63 | * 64 | * @param string $data 65 | * @return string 66 | */ 67 | public static function pack7bit($data) 68 | { 69 | $l = strlen($data); 70 | $currentByte = 0; 71 | $offset = 0; 72 | $packed = ''; 73 | for ($i = 0; $i < $l; $i++) { 74 | // cap off any excess bytes 75 | $septet = ord($data[$i]) & 0x7f; 76 | // append the septet and then cap off excess bytes 77 | $currentByte |= ($septet << $offset) & 0xff; 78 | // update offset 79 | $offset += 7; 80 | 81 | if ($offset > 7) { 82 | // the current byte is full, add it to the encoded data. 83 | $packed .= chr($currentByte); 84 | // shift left and append the left shifted septet to the current byte 85 | $currentByte = $septet = $septet >> (7 - ($offset - 8 )); 86 | // update offset 87 | $offset -= 8; // 7 - (7 - ($offset - 8)) 88 | } 89 | } 90 | if ($currentByte > 0) $packed .= chr($currentByte); // append the last byte 91 | 92 | return $packed; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Exception/SmppException.php: -------------------------------------------------------------------------------- 1 | tags as $tag) { 27 | if (Tag::MESSAGE_STATE == $tag->id) { 28 | $this->state = (int)bin2hex($tag->value); 29 | } 30 | } 31 | $this->parseDate('submit date', 'submitDate'); 32 | $this->parseDate('done date', 'doneDate'); 33 | $this->parseError(); 34 | } 35 | 36 | /** 37 | * @param string $msgText name of date in message text. Example 'submit date' 38 | * @param string $name name of current object field. Example 'submitDate' 39 | * @throws \Exception 40 | */ 41 | protected function parseDate($msgText, $name) 42 | { 43 | $matches = []; 44 | 45 | $escapedMsgText = preg_quote($msgText, '/'); 46 | $numMatches = preg_match("/$escapedMsgText:(\d{10,12})/si", $this->message, $matches); 47 | 48 | if ($numMatches === 0) { 49 | Logger::debug("Could not parse $msgText: $this->message\n" . bin2hex($this->body)); 50 | return; 51 | } 52 | 53 | if (!preg_match('/^\d{10,12}$/', $matches[1])) { 54 | Logger::debug("Invalid timestamp match: " . $matches[1]); 55 | return; 56 | } 57 | 58 | $dp = str_split($matches[1], 2); 59 | 60 | // Extra guard for array length 61 | if (count($dp) < 5) { 62 | Logger::debug("Unexpected date parts count: " . json_encode($dp)); 63 | return; 64 | } 65 | 66 | $dd = gmmktime((int)$dp[3], (int)$dp[4], isset($dp[5]) ? (int)$dp[5] : 0, (int)$dp[1], (int)$dp[2], (int)$dp[0]); 67 | 68 | $this->{$name} = new \DateTime("@$dd", new \DateTimeZone('UTC')); 69 | } 70 | protected function parseError() 71 | { 72 | $numMatches = preg_match('/err:(\d+)/si', $this->message, $matches); 73 | if ($numMatches == 0) { 74 | Logger::debug('Could not parse error code: ' . $this->message . "\n" . bin2hex($this->body)); 75 | return; 76 | } 77 | $this->receiptErrorCode = (int)$matches[1]; 78 | // TODO 79 | $this->receiptErrorText = "Code:$this->receiptErrorCode"; 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /src/Pdu/DeliverSm.php: -------------------------------------------------------------------------------- 1 | 11) throw new \InvalidArgumentException('Alphanumeric address may only contain 11 chars'); 29 | if ($ton == SMPP::TON_INTERNATIONAL && $npi == SMPP::NPI_E164 && strlen($value) > 15) throw new \InvalidArgumentException('E164 address may only contain 15 digits'); 30 | 31 | $this->value = (string)$value; 32 | $this->ton = $ton; 33 | $this->npi = $npi; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Pdu/Part/Tag.php: -------------------------------------------------------------------------------- 1 | ['length' => 1, 'type' => 'c'], 68 | self::USSD_SESSION_ID => ['length' => 4, 'type' => 'a*'], 69 | ]; 70 | 71 | /** 72 | * Construct a new TLV param. 73 | * The value must either be pre-packed with pack(), or a valid pack-type must be specified. 74 | * 75 | * @param integer $id 76 | * @param string $value 77 | * @param integer $length (optional) 78 | * @param string $type (optional) 79 | */ 80 | public function __construct($id, $value, $length = null, $type = 'a*') 81 | { 82 | $this->id = $id; 83 | $this->value = $value; 84 | $this->length = $length; 85 | $this->type = $type; 86 | } 87 | 88 | /** 89 | * Get the TLV packed into a binary string for transport 90 | * @return string 91 | */ 92 | public function getBinary() 93 | { 94 | return pack('nn' . $this->type, $this->id, ($this->length ? $this->length : strlen($this->value)), $this->value); 95 | } 96 | 97 | /** 98 | * Build Tag with predefined params 99 | * @param string $id 100 | * @param string $value 101 | * @return Tag 102 | */ 103 | public static function build($id, $value) 104 | { 105 | $params = static::ID_PARAMS[$id] ?? null; 106 | if (empty($params)) { 107 | throw new SmppException("Has no predefined params for $id"); 108 | } 109 | return new static($id, $value, ...$params); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Pdu/Part/TagUssdServiceOp.php: -------------------------------------------------------------------------------- 1 | id = $id; 27 | $this->status = $status; 28 | $this->sequence = $sequence; 29 | $this->body = $body; 30 | } 31 | 32 | public function getBinary() 33 | { 34 | $length = strlen($this->body) + 16; 35 | $header = pack("NNNN", $length, $this->id, $this->status, $this->sequence); 36 | return $header . $this->body; 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /src/Pdu/Sm.php: -------------------------------------------------------------------------------- 1 | id, $pdu->status, $pdu->sequence, $pdu->body); 39 | return $sm; 40 | } 41 | 42 | /** 43 | * @return Pdu 44 | */ 45 | public function buildResp() 46 | { 47 | $id = SMPP::DELIVER_SM_RESP; 48 | if ($this->id == SMPP::SUBMIT_SM) { 49 | $id = SMPP::SUBMIT_SM_RESP; 50 | } 51 | $response = new Pdu($id, SMPP::ESME_ROK, $this->sequence, "\x00"); 52 | return $response; 53 | } 54 | 55 | /** 56 | * Calls after filling data by PduParser 57 | */ 58 | public function afterFill() 59 | { 60 | 61 | } 62 | 63 | } -------------------------------------------------------------------------------- /src/Pdu/SmppDeliveryReceipt.php: -------------------------------------------------------------------------------- 1 | message, $matches); 31 | if ($numMatches == 0) { 32 | throw new \InvalidArgumentException('Could not parse delivery receipt: '.$this->message."\n".bin2hex($this->body)); 33 | } 34 | list($matched, $this->id, $this->sub, $this->dlvrd, $this->submitDate, $this->doneDate, $this->stat, $this->err, $this->text) = $matches; 35 | 36 | // Convert dates 37 | $dp = str_split($this->submitDate,2); 38 | $this->submitDate = gmmktime($dp[3],$dp[4],isset($dp[5]) ? $dp[5] : 0,$dp[1],$dp[2],$dp[0]); 39 | $dp = str_split($this->doneDate,2); 40 | $this->doneDate = gmmktime($dp[3],$dp[4],isset($dp[5]) ? $dp[5] : 0,$dp[1],$dp[2],$dp[0]); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Pdu/SmppSms.php: -------------------------------------------------------------------------------- 1 | service_type = $service_type; 59 | $this->source = $source; 60 | $this->destination = $destination; 61 | $this->esmClass = $esmClass; 62 | $this->protocolId = $protocolId; 63 | $this->priorityFlag = $priorityFlag; 64 | $this->registeredDelivery = $registeredDelivery; 65 | $this->dataCoding = $dataCoding; 66 | $this->message = $message; 67 | $this->tags = $tags; 68 | $this->scheduleDeliveryTime = $scheduleDeliveryTime; 69 | $this->validityPeriod = $validityPeriod; 70 | $this->smDefaultMsgId = $smDefaultMsgId; 71 | $this->replaceIfPresentFlag = $replaceIfPresentFlag; 72 | } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /src/Pdu/SubmitSm.php: -------------------------------------------------------------------------------- 1 | id, [SMPP::DELIVER_SM, SMPP::SUBMIT_SM]); 19 | } 20 | 21 | /** 22 | * @param Pdu $pdu 23 | * @return Sm 24 | */ 25 | public static function fromPdu(Pdu $pdu) 26 | { 27 | // Check command id 28 | if (!static::isSm($pdu)) { 29 | throw new \InvalidArgumentException('PDU is not an SMS'); 30 | } 31 | 32 | // Unpack PDU 33 | $ar = unpack("C*", $pdu->body); 34 | 35 | // Read mandatory params 36 | $service_type = Helper::getString($ar, 6, true); 37 | 38 | $source_addr_ton = next($ar); 39 | $source_addr_npi = next($ar); 40 | $source_addr = Helper::getString($ar, 21); 41 | $source = new Address($source_addr, $source_addr_ton, $source_addr_npi); 42 | 43 | $dest_addr_ton = next($ar); 44 | $dest_addr_npi = next($ar); 45 | $destination_addr = Helper::getString($ar, 21); 46 | $destination = new Address($destination_addr, $dest_addr_ton, $dest_addr_npi); 47 | 48 | $esmClass = next($ar); 49 | $protocolId = next($ar); 50 | $priorityFlag = next($ar); 51 | next($ar); // schedule_delivery_time 52 | next($ar); // validity_period 53 | $registeredDelivery = next($ar); 54 | next($ar); // replace_if_present_flag 55 | $dataCoding = next($ar); 56 | next($ar); // sm_default_msg_id 57 | $smLength = next($ar); 58 | $message = Helper::getString($ar, $smLength); 59 | 60 | // Check for optional params, and parse them 61 | $tags = []; 62 | if (current($ar) !== false) { 63 | $tags = array(); 64 | do { 65 | $tag = static::parseTag($ar); 66 | if ($tag !== false) { 67 | $tags[] = $tag; 68 | } 69 | } while (current($ar) !== false); 70 | } 71 | 72 | /** @var Sm $class */ 73 | $class = SubmitSm::class; 74 | if (SMPP::DELIVER_SM == $pdu->id) { 75 | if (($esmClass & SMPP::ESM_DELIVER_SMSC_RECEIPT) != 0) { 76 | $class = DeliverReceiptSm::class; 77 | } elseif ($service_type === SMPP::SERVICE_TYPE_USSD) { 78 | $class = Ussd::class; 79 | } else { 80 | $class = DeliverSm::class; 81 | } 82 | } 83 | 84 | $sm = $class::constructFromPdu($pdu); 85 | $sm->serviceType = $service_type; 86 | $sm->source = $source; 87 | $sm->destination = $destination; 88 | $sm->esmClass = $esmClass; 89 | $sm->protocolId = $protocolId; 90 | $sm->priorityFlag = $priorityFlag; 91 | $sm->registeredDelivery = $registeredDelivery; 92 | $sm->dataCoding = $dataCoding; 93 | $sm->message = $message; 94 | $sm->tags = $tags; 95 | 96 | foreach ($sm->tags as $tag) { 97 | if (Tag::RECEIPTED_MESSAGE_ID == $tag->id) { 98 | $sm->msgId = $tag->value; 99 | } 100 | } 101 | 102 | $sm->afterFill(); 103 | 104 | Logger::debug("Received sms:\n" . print_r($sm, true)); 105 | 106 | return $sm; 107 | } 108 | 109 | /** 110 | * @param $ar 111 | * @return bool|Tag 112 | */ 113 | protected static function parseTag(&$ar) 114 | { 115 | $unpackedData = unpack('nid/nlength', pack("C2C2", next($ar), next($ar), next($ar), next($ar))); 116 | if (!$unpackedData) throw new \InvalidArgumentException('Could not read tag data'); 117 | /** @var int $id */ 118 | /** @var int $length */ 119 | extract($unpackedData); 120 | 121 | // Sometimes SMSC return an extra null byte at the end 122 | if ($length == 0 && $id == 0) { 123 | return false; 124 | } 125 | 126 | $value = Helper::getOctets($ar, $length); 127 | $tag = new Tag($id, $value, $length); 128 | 129 | Logger::debug("Parsed tag:"); 130 | Logger::debug(" id :0x" . dechex($tag->id)); 131 | Logger::debug(" length :" . $tag->length); 132 | Logger::debug(" value :" . chunk_split(bin2hex($tag->value), 2, " ")); 133 | return $tag; 134 | } 135 | } -------------------------------------------------------------------------------- /src/SMPP.php: -------------------------------------------------------------------------------- 1 | openConnection(); 14 | if (Client::BIND_MODE_TRANSCEIVER == $this->bindMode) { 15 | $this->client->bindTransceiver($this->login, $this->pass); 16 | } else { 17 | $this->client->bindReceiver($this->login, $this->pass); 18 | } 19 | } 20 | 21 | /** 22 | * @param callable $callback \PhpSmpp\Pdu\Pdu passed as a parameter 23 | */ 24 | public function listen(callable $callback) 25 | { 26 | while (true) { 27 | $this->listenOnce($callback); 28 | usleep(10e4); 29 | } 30 | } 31 | 32 | public function listenOnce(callable $callback) 33 | { 34 | $this->enshureConnection(); 35 | $this->client->listenSm($callback); 36 | } 37 | 38 | } -------------------------------------------------------------------------------- /src/Service/Sender.php: -------------------------------------------------------------------------------- 1 | openConnection(); 24 | if (Client::BIND_MODE_TRANSCEIVER == $this->bindMode) { 25 | $this->client->bindTransceiver($this->login, $this->pass); 26 | } else { 27 | $this->client->bindTransmitter($this->login, $this->pass); 28 | } 29 | } 30 | 31 | public function send($phone, $message, $from) 32 | { 33 | $this->enshureConnection(); 34 | $from = new Address($from, SMPP::TON_ALPHANUMERIC); 35 | $to = new Address((int)$phone, SMPP::TON_INTERNATIONAL, SMPP::NPI_E164); 36 | 37 | $encodedMessage = $message; 38 | $dataCoding = SMPP::DATA_CODING_DEFAULT; 39 | if (Helper::hasUTFChars($message)) { 40 | $encodedMessage = iconv('UTF-8', 'UCS-2BE', $message); 41 | $dataCoding = SMPP::DATA_CODING_UCS2; 42 | } 43 | 44 | $lastError = null; 45 | $smsId = null; 46 | 47 | for ($i = 0; $i < $this->retriesCount; $i++) { 48 | try { 49 | $smsId = $this->client->sendSMS($from, $to, $encodedMessage, null, $dataCoding); 50 | } catch (\Throwable $e) { 51 | call_user_func($this->debugHandler, "Got error while sending SMS. Retry=$i"); 52 | $this->unbind(); 53 | $this->enshureConnection(); 54 | $lastError = $e; 55 | } 56 | if ($smsId) { 57 | break; 58 | } 59 | sleep($this->delayBetweenAttempts); 60 | } 61 | 62 | if (empty($smsId)) { 63 | $error = $lastError ?? new \Error("SMPP: no smsc answer"); 64 | call_user_func($this->debugHandler, $error->getMessage()); 65 | throw $error; 66 | } 67 | 68 | return $smsId; 69 | } 70 | 71 | /** 72 | * @param string $phone 73 | * @param string $message 74 | * @param string $from 75 | * @param array $tags 76 | * @return string|null 77 | */ 78 | public function sendUSSD($phone, $message, $from, array $tags) 79 | { 80 | $this->enshureConnection(); 81 | $from = new Address($from, SMPP::TON_UNKNOWN, SMPP::NPI_E164); 82 | $to = new Address((int)$phone, SMPP::TON_INTERNATIONAL, SMPP::NPI_E164); 83 | $encodedMessage = $message; 84 | $dataCoding = SMPP::DATA_CODING_DEFAULT; 85 | if (Helper::hasUTFChars($message)) { 86 | $encodedMessage = iconv('UTF-8', 'UCS-2BE', $message); 87 | $dataCoding = SMPP::DATA_CODING_UCS2_USSD; 88 | } 89 | $smsId = $this->client->sendUSSD($from, $to, $encodedMessage, $tags, $dataCoding); 90 | return $smsId; 91 | } 92 | 93 | 94 | } -------------------------------------------------------------------------------- /src/Service/Service.php: -------------------------------------------------------------------------------- 1 | hosts = $hosts; 33 | $this->login = $login; 34 | $this->pass = $pass; 35 | $this->bindMode = $bindMode; 36 | $this->debug = $debug; 37 | $this->initClient(); 38 | } 39 | 40 | abstract function bind(); 41 | 42 | protected function initClient() 43 | { 44 | if (!empty($this->client)) { 45 | return; 46 | } 47 | $this->client = new Client($this->hosts); 48 | $this->client->debug = $this->debug; 49 | $this->client->setDebugHandler($this->debugHandler); 50 | } 51 | 52 | protected function openConnection() 53 | { 54 | $this->initClient(); 55 | $this->client->getTransport()->debug = $this->debug; 56 | $this->client->getTransport()->open(); 57 | } 58 | 59 | 60 | public function unbind() 61 | { 62 | $this->client->close(); 63 | } 64 | 65 | /** 66 | * Проверим, если нет коннекта, попытаемся подключиться. Иначе кидаем исключение 67 | * @throws SocketTransportException 68 | */ 69 | public function enshureConnection() 70 | { 71 | 72 | // Когда явно нет подключения: либо ни разу не подключались либо отключились unbind 73 | if (empty($this->client)) { 74 | $this->bind(); 75 | } 76 | 77 | // Когда транспорт потерял socket_connect 78 | if (!$this->client->getTransport()->isOpen()) { 79 | $this->unbind(); 80 | $this->bind(); 81 | } 82 | 83 | try { 84 | $this->client->enquireLink(); 85 | } catch (\Throwable $e) { 86 | $this->unbind(); 87 | $this->bind(); 88 | $this->client->enquireLink(); 89 | } 90 | } 91 | 92 | public function setDebugHandler(callable $callback) 93 | { 94 | $this->debugHandler = $callback; 95 | if ($this->client) { 96 | $this->client->setDebugHandler($callback); 97 | } 98 | } 99 | 100 | } -------------------------------------------------------------------------------- /src/Tests/ReadSMSTest.php: -------------------------------------------------------------------------------- 1 | client->debug = true; 11 | $service->client->setTransport(new \PhpSmpp\Transport\FakeTransport()); 12 | /** @var \PhpSmpp\Transport\FakeTransport $transport */ 13 | $transport = $service->client->getTransport(); 14 | $transport->enqueueDeliverReceiptSm(); 15 | $service->listenOnce(function (\PhpSmpp\Pdu\Pdu $pdu) { 16 | $this->assertInstanceOf(\PhpSmpp\Pdu\DeliverReceiptSm::class, $pdu); 17 | /** @var \PhpSmpp\Pdu\DeliverReceiptSm $pdu */ 18 | $this->assertEquals('992900249911', $pdu->destination->value); 19 | $this->assertEquals(\PhpSmpp\SMPP::STATE_REJECTED, $pdu->state); 20 | $this->assertNotEmpty($pdu->msgId); 21 | }); 22 | } 23 | } -------------------------------------------------------------------------------- /src/Tests/ReadUSSDTest.php: -------------------------------------------------------------------------------- 1 | client->debug = true; 20 | $listener->client->setTransport(new FakeTransport()); 21 | 22 | $sender = new Sender([''], '', '', Client::BIND_MODE_TRANSCEIVER); 23 | $sender->client->debug = true; 24 | $sender->client->setTransport(new FakeTransport()); 25 | 26 | /** @var FakeTransport $transport */ 27 | $transport = $listener->client->getTransport(); 28 | $transport->enqueueDeliverReceiptSm('deliver_sm_enquire_link'); 29 | $transport->enqueueDeliverReceiptSm('deliver_sm_ussd'); 30 | 31 | $listener->listenOnce(function (Pdu $pdu) use ($sender) { 32 | /** @var Ussd $pdu */ 33 | $this->assertInstanceOf(Ussd::class, $pdu); 34 | $this->assertEquals(SMPP::SERVICE_TYPE_USSD, $pdu->serviceType); 35 | $this->assertEquals('992000000000', $pdu->source->value); 36 | $this->assertEquals('992000000001', $pdu->destination->value); 37 | $this->assertEquals('*3322*0#', $pdu->message); 38 | $this->assertEquals(SMPP::DELIVER_SM, $pdu->id); 39 | $this->assertEquals(SMPP::ESME_ROK, $pdu->status); 40 | 41 | $smsId = $sender->sendUSSD('992000000000', 'Your request has been accepted, wait for SMS confirmation', '3322', []); 42 | $this->assertNotEmpty($smsId, 'Has no sms id'); 43 | }); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Tests/SendSMSTest.php: -------------------------------------------------------------------------------- 1 | client->debug = true; 11 | $service->client->setTransport(new \PhpSmpp\Transport\FakeTransport()); 12 | $smsId = $service->send(79001001010, 'Hello world!', 'Sender'); 13 | $this->assertNotEmpty($smsId, 'Has no sms id'); 14 | } 15 | } -------------------------------------------------------------------------------- /src/Tests/data/deliver_receipt.hex: -------------------------------------------------------------------------------- 1 | 0000009d000000050000000000000001000101000101393932393030323439393131000400000000000000005669643a20343438303431373934207375623a303031207375626d697420646174653a3230303832383130323620646f6e6520646174653a3230303832383130323620737461743a52454a45435444206572723a303030042300030300000427000108001e000a34343830343137393400 -------------------------------------------------------------------------------- /src/Tests/data/deliver_sm_enquire_link.hex: -------------------------------------------------------------------------------- 1 | 000000100000001500000000000006a6 -------------------------------------------------------------------------------- /src/Tests/data/deliver_sm_ussd.hex: -------------------------------------------------------------------------------- 1 | 0000004a0000000500000000000001585553534400010139393230303030303030303000010139393230303030303030303100000000000000000000082a333332322a30230501000101 -------------------------------------------------------------------------------- /src/Tests/data/ussd.hex: -------------------------------------------------------------------------------- 1 | 0000004a0000000500000000000000155553534400010139393239303430383330363500010139393239303939373030313000000000000000000000082a333332322a30230501000101 -------------------------------------------------------------------------------- /src/Tests/parsedeliversm.php: -------------------------------------------------------------------------------- 1 | 0) { 27 | $body = substr($bin, 16); 28 | if (!$body) throw new \RuntimeException('Could not read PDU body'); 29 | } else { 30 | $body = null; 31 | } 32 | $pdu = new Pdu($command_id, $command_status, $sequence_number, $body); 33 | $message = PduParser::fromPdu($pdu); 34 | print_r($pdu); 35 | print_r($message); 36 | -------------------------------------------------------------------------------- /src/Tests/readsm.php: -------------------------------------------------------------------------------- 1 | client->setTransport(new \PhpSmpp\Transport\FakeTransport()); 11 | $service->listen(function (Sm $sm) { 12 | var_dump($sm->msgId); 13 | if ($sm instanceof \PhpSmpp\Pdu\DeliverReceiptSm) { 14 | var_dump($sm->state); 15 | var_dump($sm->state == \PhpSmpp\SMPP::STATE_DELIVERED); 16 | } else { 17 | echo 'not receipt'; 18 | } 19 | die; 20 | }); -------------------------------------------------------------------------------- /src/Tests/sendsm.php: -------------------------------------------------------------------------------- 1 | client->csmsMethod = \PhpSmpp\Client::CSMS_PAYLOAD; 8 | $service = new \PhpSmpp\Service\Sender([], '', '', true); 9 | $service->client->setTransport(new \PhpSmpp\Transport\FakeTransport()); 10 | //$smsId = $service->send(79824819070, 'First test', 'VIRTA'); 11 | $smsId = $service->send(44615419, 'First test Привет! Привет! Привет! Привет! Привет! Привет! Привет! Привет! Привет! Привет! Привет! Привет! Привет! Привет! Привет! Привет!', 'VIRTA'); 12 | var_dump($smsId); 13 | 14 | -------------------------------------------------------------------------------- /src/Transport/Exception/SocketTransportException.php: -------------------------------------------------------------------------------- 1 | Pdu object, 'handled' => true], ['pdu' => Pdu object, 'handled' => false] ] 25 | */ 26 | protected $sent = []; 27 | 28 | protected $queue; 29 | 30 | protected $isOpen = false; 31 | 32 | protected $dataDir = __DIR__ . '/../Tests/data'; 33 | 34 | public function enqueueDeliverReceiptSm($name = 'deliver_receipt') 35 | { 36 | $this->queue[] = $this->getPduBinary($name); 37 | } 38 | 39 | protected function getPduBinary($name) 40 | { 41 | $hex = file_get_contents("$this->dataDir/$name.hex"); 42 | $bin = hex2bin($hex); 43 | return $bin; 44 | } 45 | 46 | protected function getDeliverReceiptSm() 47 | { 48 | $sms = new DeliverReceiptSm( 49 | SMPP::DELIVER_SM, 50 | SMPP::ESME_ROK, 51 | 20, 52 | '', 53 | '', 54 | new Address('123123'), 55 | new Address('666'), 56 | 0, 0, 0, 0, 0, 57 | 'id:12345-9876 sub:001 dlvrd:000 submit date:20180721020159 done date:20180721020259 stat:ABC err:XYZ text:Sample' 58 | ); 59 | return $sms; 60 | } 61 | 62 | protected function getTestUssd() 63 | { 64 | $sms = new SmppSms( 65 | 5, 66 | SMPP::ESME_ROK, 67 | 1, 68 | 'USSDzz123123z44*666#', 69 | 'USSD', 70 | new Address('123123'), 71 | new Address('666'), 72 | 0, 0, 0, 0, 0, 73 | '*666#', 74 | [ 75 | new Tag(Tag::USSD_SERVICE_OP, 0, 1), 76 | new Tag(Tag::USER_MESSAGE_REFERENCE, '', 2), 77 | ] 78 | ); 79 | return $sms; 80 | } 81 | 82 | public function readPDU() 83 | { 84 | foreach ($this->sent as &$item) { 85 | if ($item['handled']) { 86 | continue; 87 | } 88 | $sequence = $item['pdu']->sequence; 89 | $answerMap = [ 90 | SMPP::ENQUIRE_LINK => (new Pdu(SMPP::ENQUIRE_LINK_RESP, SMPP::ESME_ROK, $sequence, "\x00"))->getBinary(), 91 | SMPP::BIND_TRANSMITTER => (new Pdu(SMPP::BIND_TRANSMITTER_RESP, SMPP::ESME_ROK, $sequence, "\x00"))->getBinary(), 92 | SMPP::BIND_RECEIVER => (new Pdu(SMPP::BIND_RECEIVER_RESP, SMPP::ESME_ROK, $sequence, "\x00"))->getBinary(), 93 | SMPP::BIND_TRANSCEIVER => (new Pdu(SMPP::BIND_TRANSCEIVER_RESP, SMPP::ESME_ROK, $sequence, "\x00"))->getBinary(), 94 | SMPP::SUBMIT_SM => (new Pdu(SMPP::SUBMIT_SM_RESP, SMPP::ESME_ROK, $sequence, rand(100000, 999999)))->getBinary(), 95 | ]; 96 | $answer = $answerMap[$item['pdu']->id] ?? null; 97 | if ($answer) { 98 | $item['handled'] = true; 99 | return $answer; 100 | } 101 | } 102 | if ($this->queue) { 103 | return array_shift($this->queue); 104 | } 105 | return null; 106 | } 107 | 108 | public function sendPDU(Pdu $pdu) 109 | { 110 | $this->sent[] = ['pdu' => $pdu, 'handled' => false]; 111 | } 112 | 113 | public function setRecvTimeout($timeout) 114 | { 115 | } 116 | 117 | public function isOpen() 118 | { 119 | return $this->isOpen; 120 | } 121 | 122 | public function open() 123 | { 124 | $this->isOpen = true; 125 | } 126 | 127 | public function close() 128 | { 129 | $this->isOpen = false; 130 | } 131 | 132 | /** 133 | * @return bool 134 | */ 135 | public function hasData() 136 | { 137 | return array_reduce( 138 | $this->sent, 139 | function ($result, $item) { 140 | return $result || $item['handled']; 141 | }, 142 | false 143 | ); 144 | } 145 | 146 | } -------------------------------------------------------------------------------- /src/Transport/SMPPSocketTransport.php: -------------------------------------------------------------------------------- 1 | body??'') + 16; 24 | $header = pack("NNNN", $length, $pdu->id, $pdu->status, $pdu->sequence); 25 | $this->write($header . $pdu->body, $length); 26 | } 27 | 28 | public function readPDU() 29 | { 30 | // Read PDU length 31 | $bufLength = $this->read(4); 32 | if (!$bufLength) { 33 | return null; 34 | } 35 | /** @var int $length */ 36 | extract(unpack("Nlength", $bufLength)); 37 | 38 | // Read PDU headers 39 | $bufHeaders = $this->read(12); 40 | if (!$bufHeaders) { 41 | return null; 42 | } 43 | 44 | // Read PDU body 45 | if ($length - 16 > 0) { 46 | $body = $this->readAll($length - 16); 47 | if (!$body) throw new \RuntimeException('Could not read PDU body'); 48 | } else { 49 | $body = null; 50 | } 51 | return $bufLength . $bufHeaders . $body; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Transport/SocketTransport.php: -------------------------------------------------------------------------------- 1 | debug = self::$defaultDebug; 44 | $this->debugHandler = $debugHandler ? $debugHandler : 'error_log'; 45 | 46 | // Deal with optional port 47 | $h = array(); 48 | foreach ($hosts as $key => $host) { 49 | $h[] = array($host, is_array($ports) ? $ports[$key] : $ports); 50 | } 51 | if (self::$randomHost) shuffle($h); 52 | $this->resolveHosts($h); 53 | 54 | $this->persist = $persist; 55 | } 56 | 57 | /** 58 | * Resolve the hostnames into IPs, and sort them into IPv4 or IPv6 groups. 59 | * If using DNS hostnames, and all lookups fail, a InvalidArgumentException is thrown. 60 | * 61 | * @param array $hosts 62 | * @throws InvalidArgumentException 63 | */ 64 | protected function resolveHosts($hosts) 65 | { 66 | $i = 0; 67 | foreach ($hosts as $host) { 68 | list($hostname, $port) = $host; 69 | $ip4s = array(); 70 | $ip6s = array(); 71 | if (preg_match('/^([12]?[0-9]?[0-9]\.){3}([12]?[0-9]?[0-9])$/', $hostname)) { 72 | // IPv4 address 73 | $ip4s[] = $hostname; 74 | } else if (preg_match('/^([0-9a-f:]+):[0-9a-f]{1,4}$/i', $hostname)) { 75 | // IPv6 address 76 | $ip6s[] = $hostname; 77 | } else { // Do a DNS lookup 78 | if (!self::$forceIpv4) { 79 | // if not in IPv4 only mode, check the AAAA records first 80 | $records = dns_get_record($hostname, DNS_AAAA); 81 | if ($records === false && $this->debug) call_user_func($this->debugHandler, 'DNS lookup for AAAA records for: ' . $hostname . ' failed'); 82 | if ($records) { 83 | foreach ($records as $r) { 84 | if (isset($r['ipv6']) && $r['ipv6']) $ip6s[] = $r['ipv6']; 85 | } 86 | } 87 | if ($this->debug) call_user_func($this->debugHandler, "IPv6 addresses for $hostname: " . implode(', ', $ip6s)); 88 | } 89 | if (!self::$forceIpv6) { 90 | // if not in IPv6 mode check the A records also 91 | $records = dns_get_record($hostname, DNS_A); 92 | if ($records === false && $this->debug) call_user_func($this->debugHandler, 'DNS lookup for A records for: ' . $hostname . ' failed'); 93 | if ($records) { 94 | foreach ($records as $r) { 95 | if (isset($r['ip']) && $r['ip']) $ip4s[] = $r['ip']; 96 | } 97 | } 98 | // also try gethostbyname, since name could also be something else, such as "localhost" etc. 99 | $ip = gethostbyname($hostname); 100 | if ($ip != $hostname && !in_array($ip, $ip4s)) $ip4s[] = $ip; 101 | if ($this->debug) call_user_func($this->debugHandler, "IPv4 addresses for $hostname: " . implode(', ', $ip4s)); 102 | } 103 | } 104 | 105 | // Did we get any results? 106 | if (self::$forceIpv4 && empty($ip4s)) continue; 107 | if (self::$forceIpv6 && empty($ip6s)) continue; 108 | if (empty($ip4s) && empty($ip6s)) continue; 109 | 110 | if ($this->debug) $i += count($ip4s) + count($ip6s); 111 | 112 | // Add results to pool 113 | $this->hosts[] = array($hostname, $port, $ip6s, $ip4s); 114 | } 115 | if ($this->debug) call_user_func($this->debugHandler, "Built connection pool of " . count($this->hosts) . " host(s) with $i ip(s) in total"); 116 | if (empty($this->hosts)) throw new \InvalidArgumentException('No valid hosts was found'); 117 | } 118 | 119 | /** 120 | * Get a reference to the socket. 121 | * You should use the public functions rather than the socket directly 122 | */ 123 | public function getSocket() 124 | { 125 | return $this->socket; 126 | } 127 | 128 | /** 129 | * Get an arbitrary option 130 | * 131 | * @param integer $option 132 | * @param integer $lvl 133 | */ 134 | public function getSocketOption($option, $lvl = SOL_SOCKET) 135 | { 136 | return socket_get_option($this->socket, $lvl, $option); 137 | } 138 | 139 | /** 140 | * Set an arbitrary option 141 | * 142 | * @param integer $option 143 | * @param mixed $value 144 | * @param integer $lvl 145 | */ 146 | public function setSocketOption($option, $value, $lvl = SOL_SOCKET) 147 | { 148 | return socket_set_option($this->socket, $lvl, $option, $value); 149 | } 150 | 151 | /** 152 | * Sets the send timeout. 153 | * Returns true on success, or false. 154 | * @param int $timeout Timeout in milliseconds. 155 | * @return boolean 156 | */ 157 | public function setSendTimeout($timeout) 158 | { 159 | if (!$this->isOpen()) { 160 | self::$defaultSendTimeout = $timeout; 161 | } else { 162 | $r = socket_set_option($this->socket, SOL_SOCKET, SO_SNDTIMEO, $this->millisecToSolArray($timeout)); 163 | return $r; 164 | } 165 | } 166 | 167 | /** 168 | * Sets the receive timeout. 169 | * Returns true on success, or false. 170 | * @param int $timeout Timeout in milliseconds. 171 | * @return boolean 172 | */ 173 | public function setRecvTimeout($timeout) 174 | { 175 | if (!$this->isOpen()) { 176 | self::$defaultRecvTimeout = $timeout; 177 | } else { 178 | $r = socket_set_option($this->socket, SOL_SOCKET, SO_RCVTIMEO, $this->millisecToSolArray($timeout)); 179 | return $r; 180 | } 181 | } 182 | 183 | /** 184 | * Check if the socket is constructed, and there are no exceptions on it 185 | * Returns false if it's closed. 186 | * Throws SocketTransportException is state could not be ascertained 187 | * @throws SocketTransportException 188 | */ 189 | public function isOpen() 190 | { 191 | if (!is_resource($this->socket) && !$this->socket instanceof \Socket) return false; 192 | $r = null; 193 | $w = null; 194 | $e = array($this->socket); 195 | $res = socket_select($r, $w, $e, 0); 196 | if ($res === false) throw new SocketTransportException('Could not examine socket; ' . socket_strerror(socket_last_error()), socket_last_error()); 197 | if (!empty($e)) return false; // if there is an exception on our socket it's probably dead 198 | return true; 199 | } 200 | 201 | /** 202 | * Convert a milliseconds into a socket sec+usec array 203 | * @param integer $millisec 204 | * @return array 205 | */ 206 | protected function millisecToSolArray($millisec) 207 | { 208 | $usec = $millisec * 1000; 209 | return array('sec' => floor($usec / 1000000), 'usec' => $usec % 1000000); 210 | } 211 | 212 | /** 213 | * Open the socket, trying to connect to each host in succession. 214 | * This will prefer IPv6 connections if forceIpv4 is not enabled. 215 | * If all hosts fail, a SocketTransportException is thrown. 216 | * 217 | * @throws SocketTransportException 218 | */ 219 | public function open() 220 | { 221 | if (!self::$forceIpv4) { 222 | $socket6 = @socket_create(AF_INET6, SOCK_STREAM, SOL_TCP); 223 | if ($socket6 == false) throw new SocketTransportException('Could not create socket; ' . socket_strerror(socket_last_error()), socket_last_error()); 224 | socket_set_option($socket6, SOL_SOCKET, SO_SNDTIMEO, $this->millisecToSolArray(self::$defaultSendTimeout)); 225 | socket_set_option($socket6, SOL_SOCKET, SO_RCVTIMEO, $this->millisecToSolArray(self::$defaultRecvTimeout)); 226 | } 227 | if (!self::$forceIpv6) { 228 | $socket4 = @socket_create(AF_INET, SOCK_STREAM, SOL_TCP); 229 | if ($socket4 == false) throw new SocketTransportException('Could not create socket; ' . socket_strerror(socket_last_error()), socket_last_error()); 230 | socket_set_option($socket4, SOL_SOCKET, SO_SNDTIMEO, $this->millisecToSolArray(self::$defaultSendTimeout)); 231 | socket_set_option($socket4, SOL_SOCKET, SO_RCVTIMEO, $this->millisecToSolArray(self::$defaultRecvTimeout)); 232 | } 233 | $it = new \ArrayIterator($this->hosts); 234 | while ($it->valid()) { 235 | list($hostname, $port, $ip6s, $ip4s) = $it->current(); 236 | if (!self::$forceIpv4 && !empty($ip6s)) { // Attempt IPv6s first 237 | foreach ($ip6s as $ip) { 238 | if ($this->debug) call_user_func($this->debugHandler, "Connecting to $ip:$port..."); 239 | $r = @socket_connect($socket6, $ip, $port); 240 | if ($r) { 241 | if ($this->debug) call_user_func($this->debugHandler, "Connected to $ip:$port!"); 242 | if (!empty($socket4)) { 243 | @socket_close($socket4); 244 | } 245 | $this->socket = $socket6; 246 | return; 247 | } elseif ($this->debug) { 248 | call_user_func($this->debugHandler, "Socket connect to $ip:$port failed; " . socket_strerror(socket_last_error())); 249 | } 250 | } 251 | } 252 | if (!self::$forceIpv6 && !empty($ip4s)) { 253 | foreach ($ip4s as $ip) { 254 | if ($this->debug) call_user_func($this->debugHandler, "Connecting to $ip:$port..."); 255 | $r = @socket_connect($socket4, $ip, $port); 256 | if ($r) { 257 | if ($this->debug) call_user_func($this->debugHandler, "Connected to $ip:$port!"); 258 | if (!empty($socket6)) { 259 | @socket_close($socket6); 260 | } 261 | $this->socket = $socket4; 262 | return; 263 | } elseif ($this->debug) { 264 | call_user_func($this->debugHandler, "Socket connect to $ip:$port failed; " . socket_strerror(socket_last_error())); 265 | } 266 | } 267 | } 268 | $it->next(); 269 | } 270 | throw new SocketTransportException('Could not connect to any of the specified hosts'); 271 | } 272 | 273 | /** 274 | * Do a clean shutdown of the socket. 275 | * Since we don't reuse sockets, we can just close and forget about it, 276 | * but we choose to wait (linger) for the last data to come through. 277 | */ 278 | public function close() 279 | { 280 | if (!$this->socket instanceof \Socket) { 281 | return; 282 | } 283 | 284 | $arrOpt = array('l_onoff' => 1, 'l_linger' => 1); 285 | socket_set_block($this->socket); 286 | socket_set_option($this->socket, SOL_SOCKET, SO_LINGER, $arrOpt); 287 | socket_close($this->socket); 288 | $this->socket = null; 289 | } 290 | 291 | /** 292 | * Check if there is data waiting for us on the wire 293 | * @return boolean 294 | * @throws SocketTransportException 295 | */ 296 | public function hasData() 297 | { 298 | $r = array($this->socket); 299 | $w = null; 300 | $e = null; 301 | $res = socket_select($r, $w, $e, 0); 302 | if ($res === false) throw new SocketTransportException('Could not examine socket; ' . socket_strerror(socket_last_error()), socket_last_error()); 303 | if (!empty($r)) return true; 304 | return false; 305 | } 306 | 307 | /** 308 | * Read up to $length bytes from the socket. 309 | * Does not guarantee that all the bytes are read. 310 | * Returns false on EOF 311 | * Returns false on timeout (technically EAGAIN error). 312 | * Throws SocketTransportException if data could not be read. 313 | * 314 | * @param integer $length 315 | * @return mixed 316 | * @throws SocketTransportException 317 | */ 318 | public function read($length) 319 | { 320 | $d = socket_read($this->socket, $length, PHP_BINARY_READ); 321 | if ($d === false && socket_last_error() === SOCKET_EAGAIN) return false; // sockets give EAGAIN on timeout 322 | if ($d === false) throw new SocketTransportException('Could not read ' . $length . ' bytes from socket; ' . socket_strerror(socket_last_error()), socket_last_error()); 323 | if ($d === '') return false; 324 | return $d; 325 | } 326 | 327 | /** 328 | * Read all the bytes, and block until they are read. 329 | * Timeout throws SocketTransportException 330 | * 331 | * @param integer $length 332 | */ 333 | public function readAll($length) 334 | { 335 | $d = ""; 336 | $r = 0; 337 | $readTimeout = socket_get_option($this->socket, SOL_SOCKET, SO_RCVTIMEO); 338 | while ($r < $length) { 339 | $buf = ''; 340 | $r += socket_recv($this->socket, $buf, $length - $r, MSG_DONTWAIT); 341 | if ($r === false) throw new SocketTransportException('Could not read ' . $length . ' bytes from socket; ' . socket_strerror(socket_last_error()), socket_last_error()); 342 | $d .= $buf; 343 | if ($r == $length) return $d; 344 | 345 | // wait for data to be available, up to timeout 346 | $r = array($this->socket); 347 | $w = null; 348 | $e = array($this->socket); 349 | $res = socket_select($r, $w, $e, $readTimeout['sec'], $readTimeout['usec']); 350 | 351 | // check 352 | if ($res === false) throw new SocketTransportException('Could not examine socket; ' . socket_strerror(socket_last_error()), socket_last_error()); 353 | if (!empty($e)) throw new SocketTransportException('Socket exception while waiting for data; ' . socket_strerror(socket_last_error()), socket_last_error()); 354 | if (empty($r)) throw new SocketTransportException('Timed out waiting for data on socket'); 355 | } 356 | } 357 | 358 | /** 359 | * Write (all) data to the socket. 360 | * Timeout throws SocketTransportException 361 | * 362 | * @param string $buffer 363 | * @param integer $length 364 | */ 365 | public function write($buffer, $length) 366 | { 367 | $r = $length; 368 | $writeTimeout = socket_get_option($this->socket, SOL_SOCKET, SO_SNDTIMEO); 369 | 370 | while ($r > 0) { 371 | $wrote = socket_write($this->socket, $buffer, $r); 372 | if ($wrote === false) throw new SocketTransportException('Could not write ' . $length . ' bytes to socket; ' . socket_strerror(socket_last_error()), socket_last_error()); 373 | $r -= $wrote; 374 | if ($r == 0) return; 375 | 376 | $buffer = substr($buffer, $wrote); 377 | 378 | // wait for the socket to accept more data, up to timeout 379 | $r = null; 380 | $w = array($this->socket); 381 | $e = array($this->socket); 382 | $res = socket_select($r, $w, $e, $writeTimeout['sec'], $writeTimeout['usec']); 383 | 384 | // check 385 | if ($res === false) throw new SocketTransportException('Could not examine socket; ' . socket_strerror(socket_last_error()), socket_last_error()); 386 | if (!empty($e)) throw new SocketTransportException('Socket exception while waiting to write data; ' . socket_strerror(socket_last_error()), socket_last_error()); 387 | if (empty($w)) throw new SocketTransportException('Timed out waiting to write data on socket'); 388 | } 389 | } 390 | } 391 | -------------------------------------------------------------------------------- /src/Transport/TransportInterface.php: -------------------------------------------------------------------------------- 1 |