├── README.md ├── composer.json └── src ├── .gitignore ├── Address.php ├── Client.php ├── DeliveryReceipt.php ├── Pdu.php ├── SMPP.php ├── Sms.php ├── Tag.php ├── exceptions ├── SmppException.php └── SocketTransportException.php ├── helpers └── GsmEncoderHelper.php └── transport └── Socket.php /README.md: -------------------------------------------------------------------------------- 1 | PHP SMPP (v3.4) client 2 | ==== 3 | 4 | Install: 5 | 6 | composer require alexandr-mironov/php-smpp 7 | 8 | Example of wrapper (php>=7.0) for this Client. 9 | In this case we got ALPHANUMERIC sender value 'github_example': 10 | 11 | ```php 12 | transport = new Socket([$address], $port); 48 | $this->transport->setRecvTimeout($timeout); 49 | $this->smppClient = new SmppClient($this->transport); 50 | 51 | // Activate binary hex-output of server interaction 52 | $this->smppClient->debug = $debug; 53 | $this->transport->debug = $debug; 54 | 55 | $this->login = $login; 56 | $this->password = $password; 57 | 58 | $this->from = new Address(self::DEFAULT_SENDER,SMPP::TON_ALPHANUMERIC); 59 | } 60 | 61 | /** 62 | * @param $sender 63 | * @param $ton 64 | * @return $this 65 | * @throws \Exception 66 | */ 67 | public function setSender($sender, $ton) 68 | { 69 | return $this->setAddress($sender, 'from', $ton); 70 | } 71 | 72 | /** 73 | * @param $address 74 | * @param $ton 75 | * @return $this 76 | * @throws \Exception 77 | */ 78 | public function setRecipient($address, $ton) 79 | { 80 | return $this->setAddress($address, 'to', $ton); 81 | } 82 | 83 | /** 84 | * @param $address 85 | * @param string $type 86 | * @param int $ton 87 | * @param int $npi 88 | * @return $this 89 | * @throws \Exception 90 | */ 91 | protected function setAddress($address, string $type, $ton = SMPP::TON_UNKNOWN, $npi = SMPP::NPI_UNKNOWN) 92 | { 93 | // some example of data preparation 94 | if($ton === SMPP::TON_INTERNATIONAL){ 95 | $npi = SMPP::NPI_E164; 96 | } 97 | $this->$type = new Address($address, $ton, $npi); 98 | return $this; 99 | } 100 | 101 | /** 102 | * @param string $message 103 | */ 104 | public function sendMessage(string $message) 105 | { 106 | $this->transport->open(); 107 | $this->smppClient->bindTransceiver($this->login,$this->password); 108 | // strongly recommend use SMPP::DATA_CODING_UCS2 as default encoding in project to prevent problems with non latin symbols 109 | $this->smppClient->sendSMS($this->from, $this->to, $message, null, SMPP::DATA_CODING_UCS2); 110 | $this->smppClient->close(); 111 | } 112 | } 113 | ``` 114 | 115 | This wrapper implement some kind of Builder pattern, usage example: 116 | ```php 117 | setRecipient('79000000000', \smpp\SMPP::TON_INTERNATIONAL) //msisdn of recipient 121 | ->sendMessage('Тестовое сообщение на русском and @noth3r$Ymb0ls'); 122 | ``` 123 | 124 | Original description 125 | ======= 126 | PHP-based SMPP client lib 127 | ============= 128 | 129 | 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). 130 | 131 | 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. 132 | 133 | This lib has changed significantly from it's first release, which required namespaces and included some worker components. You'll find that release at [1.0.1-namespaced](https://github.com/onlinecity/php-smpp/tree/1.0.1-namespaced) 134 | 135 | This lib requires the [sockets](http://www.php.net/manual/en/book.sockets.php) PHP-extension, and is not supported on Windows. A [windows-compatible](https://github.com/onlinecity/php-smpp/tree/windows-compatible) version is also available. 136 | 137 | 138 | Connection pools 139 | ----- 140 | 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. 141 | If you want to monitor the DNS lookups, set defaultDebug to true before constructing the transport. 142 | 143 | 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. 144 | 145 | 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. 146 | 147 | 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". 148 | 149 | 150 | Implementation notes 151 | ----- 152 | 153 | - You can't connect as a transceiver, otherwise supported by SMPP v.3.4 154 | - 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. 155 | - The sockets will return false if the timeout is reached on read() (but not readAll or write). 156 | You can use this feature to implement an enquire_link policy. If you need to send enquire_link for every 30 seconds of inactivity, 157 | set a timeout of 30 seconds, and send the enquire_link command after readSMS() returns false. 158 | - The examples above assume that the SMSC default datacoding is [GSM 03.38](http://en.wikipedia.org/wiki/GSM_03.38). 159 | - Remember to activate registered delivery if you want delivery receipts (set to SMPP::REG_DELIVERY_SMSC_BOTH / 0x01). 160 | - 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. 161 | 162 | F.A.Q. 163 | ----- 164 | 165 | **Can I use this to send messages from my website?** 166 | Not on it's own, no. After PHP processes the request on a website, it closes all connections. Most SMPP providers do not want you to open and close connections, you should keep them alive and send enquire_link commands periodically. Which means you probably need to get some kind of long running process, ie. using the [process control functions](http://www.php.net/manual/en/book.pcntl.php), and implement a form of queue system which you can push to from the website. This requires shell level access to the server, and knowledge of unix processes. 167 | 168 | **How do I receive delivery receipts or SMS'es?** 169 | To receive a delivery receipt or a SMS you must connect a receiver in addition to the transmitter. This receiver must wait for a delivery receipt to arrive, which means you probably need to use the [process control functions](http://www.php.net/manual/en/book.pcntl.php). 170 | 171 | We do have an open source implementation at [php-smpp-worker](https://github.com/onlinecity/php-smpp-worker) you can look at for inspiration, but we cannot help you with making your own. Perhaps you should look into if your SMSC provider can give you a HTTP based API or using turnkey software such as [kannel](http://www.kannel.org/), this project provides the protocol implementation only and a basic socket wrapper. 172 | 173 | **I can't send more than 160 chars** 174 | 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. 175 | 176 | **Is this lib compatible with PHP 5.2.x ?** 177 | It's tested on PHP 5.3, but is known to work with 5.2 as well. 178 | 179 | **Can it run on windows?** 180 | It requires the sockets extension, which is available on windows, but is incomplete. Use the [windows-compatible](https://github.com/onlinecity/php-smpp/tree/windows-compatible) version instead, which uses fsockopen and stream functions. 181 | 182 | **Why am I not seeing any debug output?** 183 | 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. 184 | 185 | **Why do I get 'res_nsend() failed' or 'Could not connect to any of the specified hosts' errors?** 186 | 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. 187 | 188 | **I tried forcing IPv4 and/or specifying an IP-address, but I'm still getting 'Could not connect to any of the specified hosts'?** 189 | 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. 190 | 191 | **Why do I get 'Failed to read reply to command: 0x4', 'Message Length is invalid' or 'Error in optional part' errors?** 192 | 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. 193 | 194 | **What does 'Bind Failed' mean?** 195 | It typically means your SMPP provider rejected your login credentials, ie. your username or password. 196 | 197 | **Can I test the client library without a SMPP server?** 198 | Many service providers can give you a demo account, but you can also use the [logica opensmpp simulator](http://opensmpp.logica.com/CommonPart/Introduction/Introduction.htm#simulator) (java) or [smsforum client test tool](http://www.smsforum.net/sctt_v1.0.Linux.tar.gz) (linux binary). In addition to a number of real-life SMPP servers this library is tested against these simulators. 199 | 200 | **I have an issue that not mentioned here, what do I do?** 201 | 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. 202 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "alexandr-mironov/php-smpp", 3 | "description": "PHP SMPP client lib, fork of onlinecity/php-smpp", 4 | "keywords": ["smpp", "sms", "texting", "gsm"], 5 | "homepage": "https://github.com/alexandr-mironov/php-smpp", 6 | "type": "library", 7 | "license": "LGPL-2.0-or-later", 8 | "authors": [ 9 | { 10 | "name": "Alexandr Mironov", 11 | "email": "alex@hyptrex.com" 12 | }, 13 | { 14 | "name": "Hans Duedal", 15 | "email": "hd@oc.dk", 16 | "homepage": "http://oc.dk" 17 | } 18 | ], 19 | "require": { 20 | "php": ">=5.4", 21 | "ext-sockets": "*", 22 | "ext-mbstring": "*" 23 | }, 24 | "require-dev": { 25 | }, 26 | "autoload": { 27 | "psr-4": { 28 | "smpp\\": "src/" 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/.gitignore: -------------------------------------------------------------------------------- 1 | .idea -------------------------------------------------------------------------------- /src/Address.php: -------------------------------------------------------------------------------- 1 | 11) { 32 | throw new \InvalidArgumentException('Alphanumeric address may only contain 11 chars'); 33 | } 34 | if ($ton == SMPP::TON_INTERNATIONAL && $npi == SMPP::NPI_E164 && strlen($value) > 15) { 35 | throw new \InvalidArgumentException('E164 address may only contain 15 digits'); 36 | } 37 | 38 | $this->value = (string) $value; 39 | $this->ton = $ton; 40 | $this->npi = $npi; 41 | } 42 | } -------------------------------------------------------------------------------- /src/Client.php: -------------------------------------------------------------------------------- 1 | sequenceNumber = 1; 117 | $this->debug = false; 118 | $this->pduQueue = []; 119 | 120 | $this->transport = $transport; 121 | $this->debugHandler = $debugHandler ? $debugHandler : 'error_log'; 122 | $this->mode = null; 123 | } 124 | 125 | /** 126 | * Binds the receiver. One object can be bound only as receiver or only as transmitter. 127 | * @param string $login - ESME system_id 128 | * @param string $pass - ESME password 129 | * @return bool 130 | * @throws SmppException 131 | * @throws Exception 132 | */ 133 | public function bindReceiver($login, $pass) 134 | { 135 | if (!$this->transport->isOpen()) { 136 | throw new SocketTransportException('Socket is not open'); 137 | } 138 | if ($this->debug) { 139 | call_user_func($this->debugHandler, 'Binding receiver...'); 140 | } 141 | 142 | $response = $this->bind($login, $pass, SMPP::BIND_RECEIVER); 143 | 144 | if ($this->debug) { 145 | call_user_func($this->debugHandler, "Binding status : " . $response->status); 146 | } 147 | $this->mode = self::MODE_RECEIVER; 148 | $this->login = $login; 149 | $this->pass = $pass; 150 | } 151 | 152 | /** 153 | * Binds the transmitter. One object can be bound only as receiver or only as transmitter. 154 | * @param string $login - ESME system_id 155 | * @param string $pass - ESME password 156 | * @return bool 157 | * @throws SmppException 158 | * @throws Exception 159 | */ 160 | public function bindTransmitter($login, $pass) 161 | { 162 | if (!$this->transport->isOpen()) { 163 | throw new SocketTransportException('Socket is not open'); 164 | } 165 | 166 | if ($this->debug) { 167 | call_user_func($this->debugHandler, 'Binding transmitter...'); 168 | } 169 | 170 | $response = $this->bind($login, $pass, SMPP::BIND_TRANSMITTER); 171 | 172 | if ($this->debug) { 173 | call_user_func($this->debugHandler, "Binding status : " . $response->status); 174 | } 175 | $this->mode = self::MODE_TRANSMITTER; 176 | $this->login = $login; 177 | $this->pass = $pass; 178 | } 179 | 180 | /** 181 | * @param $login 182 | * @param $pass 183 | * @return bool 184 | * @throws Exception 185 | */ 186 | public function bindTransceiver($login, $pass) 187 | { 188 | if (!$this->transport->isOpen()) { 189 | throw new SocketTransportException('Socket is not open'); 190 | } 191 | 192 | $response = $this->bind($login, $pass, SMPP::BIND_TRANSCEIVER); 193 | 194 | if ($this->debug) { 195 | call_user_func($this->debugHandler, "Binding status : " . $response->status); 196 | } 197 | $this->mode = self::MODE_TRANSCEIVER; 198 | $this->login = $login; 199 | $this->pass = $pass; 200 | } 201 | 202 | /** 203 | * Closes the session on the SMSC server. 204 | */ 205 | public function close() 206 | { 207 | if (!$this->transport->isOpen()) { 208 | return; 209 | } 210 | 211 | if ($this->debug) { 212 | call_user_func($this->debugHandler, 'Unbinding...'); 213 | } 214 | 215 | $response = $this->sendCommand(SMPP::UNBIND, ""); 216 | 217 | if ($this->debug) { 218 | call_user_func($this->debugHandler, "Unbind status : " . $response->status); 219 | } 220 | $this->transport->close(); 221 | } 222 | 223 | /** 224 | * Parse a timestring as formatted by SMPP v3.4 section 7.1. 225 | * Returns an unix timestamp if $newDates is false or DateTime/DateInterval is missing, 226 | * otherwise an object of either DateTime or DateInterval is returned. 227 | * 228 | * @param string $input 229 | * @param boolean $newDates 230 | * @return mixed 231 | * @throws Exception 232 | */ 233 | public function parseSmppTime($input, $newDates = true) 234 | { 235 | // Check for support for new date classes 236 | if (!class_exists('DateTime') || !class_exists('DateInterval')) $newDates = false; 237 | 238 | $numMatch = preg_match('/^(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{1})(\\d{2})([R+-])$/', $input, $matches); 239 | if (!$numMatch) return null; 240 | list($whole, $y, $m, $d, $h, $i, $s, $t, $n, $p) = $matches; 241 | 242 | // Use strtotime to convert relative time into a unix timestamp 243 | if ($p == 'R') { 244 | if ($newDates) { 245 | $spec = "P"; 246 | if ($y) $spec .= $y . 'Y'; 247 | if ($m) $spec .= $m . 'M'; 248 | if ($d) $spec .= $d . 'D'; 249 | if ($h || $i || $s) $spec .= 'T'; 250 | if ($h) $spec .= $h . 'H'; 251 | if ($i) $spec .= $i . 'M'; 252 | if ($s) $spec .= $s . 'S'; 253 | return new \DateInterval($spec); 254 | } else { 255 | return strtotime("+$y year +$m month +$d day +$h hour +$i minute $s +second"); 256 | } 257 | } else { 258 | $offsetHours = floor($n / 4); 259 | $offsetMinutes = ($n % 4) * 15; 260 | $time = sprintf("20%02s-%02s-%02sT%02s:%02s:%02s%s%02s:%02s", $y, $m, $d, $h, $i, $s, $p, $offsetHours, $offsetMinutes); // Not Y3K safe 261 | if ($newDates) { 262 | return new \DateTime($time); 263 | } else { 264 | return strtotime($time); 265 | } 266 | } 267 | } 268 | 269 | /** 270 | * Query the SMSC about current state/status of a previous sent SMS. 271 | * You must specify the SMSC assigned message id and source of the sent SMS. 272 | * Returns an associative array with elements: message_id, final_date, message_state and error_code. 273 | * message_state would be one of the SMPP::STATE_* constants. (SMPP v3.4 section 5.2.28) 274 | * error_code depends on the telco network, so could be anything. 275 | * 276 | * @param string $messageID 277 | * @param Address $source 278 | * @return array 279 | * @throws Exception 280 | */ 281 | public function queryStatus($messageID, Address $source) 282 | { 283 | $pduBody = pack( 284 | 'a' . (strlen($messageID) + 1) . 'cca' . (strlen($source->value) + 1), 285 | $messageID, 286 | $source->ton, 287 | $source->npi, 288 | $source->value 289 | ); 290 | $reply = $this->sendCommand(SMPP::QUERY_SM, $pduBody); 291 | if (!$reply || $reply->status != SMPP::ESME_ROK) { 292 | return null; 293 | } 294 | 295 | // Parse reply 296 | $posID = strpos($reply->body, "\0", 0); 297 | $posDate = strpos($reply->body, "\0", $posID + 1); 298 | $data = []; 299 | $data['message_id'] = substr($reply->body, 0, $posID); 300 | $data['final_date'] = substr($reply->body, $posID, $posDate - $posID); 301 | $data['final_date'] = $data['final_date'] ? $this->parseSmppTime(trim($data['final_date'])) : null; 302 | $status = unpack("cmessage_state/cerror_code", substr($reply->body, $posDate + 1)); 303 | return array_merge($data, $status); 304 | } 305 | 306 | /** 307 | * Read one SMS from SMSC. Can be executed only after bindReceiver() call. 308 | * This method bloks. Method returns on socket timeout or enquire_link signal from SMSC. 309 | * @return DeliveryReceipt|Sms|bool 310 | */ 311 | public function readSMS() 312 | { 313 | $commandID = SMPP::DELIVER_SM; 314 | // Check the queue 315 | $queueLength = count($this->pduQueue); 316 | for ($i = 0; $i < $queueLength; $i++) { 317 | $pdu = $this->pduQueue[$i]; 318 | if ($pdu->id == $commandID) { 319 | //remove response 320 | array_splice($this->pduQueue, $i, 1); 321 | return $this->parseSMS($pdu); 322 | } 323 | } 324 | // Read pdu 325 | do { 326 | $pdu = $this->readPDU(); 327 | if ($pdu === false) { 328 | return false; 329 | } // TSocket v. 0.6.0+ returns false on timeout 330 | //check for enquire link command 331 | if ($pdu->id == SMPP::ENQUIRE_LINK) { 332 | $response = new Pdu(SMPP::ENQUIRE_LINK_RESP, SMPP::ESME_ROK, $pdu->sequence, "\x00"); 333 | $this->sendPDU($response); 334 | } else if ($pdu->id != $commandID) { // if this is not the correct PDU add to queue 335 | array_push($this->pduQueue, $pdu); 336 | } 337 | } while ($pdu && $pdu->id != $commandID); 338 | 339 | if ($pdu) { 340 | return $this->parseSMS($pdu); 341 | } 342 | return false; 343 | } 344 | 345 | /** 346 | * Send one SMS to SMSC. Can be executed only after bindTransmitter() call. 347 | * $message is always in octets regardless of the data encoding. 348 | * For correct handling of Concatenated SMS, message must be encoded with GSM 03.38 (data_coding 0x00) or UCS-2BE (0x08). 349 | * Concatenated SMS'es uses 16-bit reference numbers, which gives 152 GSM 03.38 chars or 66 UCS-2BE chars per CSMS. 350 | * If we are using 8-bit ref numbers in the UDH for CSMS it's 153 GSM 03.38 chars 351 | * 352 | * @param Address $from 353 | * @param Address $to 354 | * @param string $message 355 | * @param array $tags (optional) 356 | * @param integer $dataCoding (optional) 357 | * @param integer $priority (optional) 358 | * @param string $scheduleDeliveryTime (optional) 359 | * @param string $validityPeriod (optional) 360 | * @return string message id 361 | */ 362 | public function sendSMS( 363 | Address $from, 364 | Address $to, 365 | $message, 366 | $tags = null, 367 | $dataCoding = SMPP::DATA_CODING_DEFAULT, 368 | $priority = 0x00, 369 | $scheduleDeliveryTime = null, 370 | $validityPeriod = null 371 | ) 372 | { 373 | $messageLength = strlen($message); 374 | 375 | if ($messageLength > 160 && $dataCoding != SMPP::DATA_CODING_UCS2 && $dataCoding != SMPP::DATA_CODING_DEFAULT) { 376 | return false; 377 | } 378 | 379 | switch ($dataCoding) { 380 | case SMPP::DATA_CODING_UCS2: 381 | // in octets, 70 UCS-2 chars 382 | $singleSmsOctetLimit = 140; 383 | // There are 133 octets available, but this would split the UCS the middle so use 132 instead 384 | $csmsSplit = 132; 385 | $message = mb_convert_encoding($message, 'UCS-2'); 386 | //Update message length with current encoding 387 | $messageLength = mb_strlen($message); 388 | break; 389 | case SMPP::DATA_CODING_DEFAULT: 390 | // we send data in octets, but GSM 03.38 will be packed in septets (7-bit) by SMSC. 391 | $singleSmsOctetLimit = 160; 392 | // send 152/153 chars in each SMS (SMSC will format data) 393 | $csmsSplit = (self::$csmsMethod == self::CSMS_8BIT_UDH) ? 153 : 152; 394 | break; 395 | default: 396 | $singleSmsOctetLimit = 254; // From SMPP standard 397 | break; 398 | } 399 | 400 | // Figure out if we need to do CSMS, since it will affect our PDU 401 | if ($messageLength > $singleSmsOctetLimit) { 402 | $doCsms = true; 403 | if (self::$csmsMethod != self::CSMS_PAYLOAD) { 404 | $parts = $this->splitMessageString($message, $csmsSplit, $dataCoding); 405 | $short_message = reset($parts); 406 | $csmsReference = $this->getCsmsReference(); 407 | } 408 | } else { 409 | $short_message = $message; 410 | $doCsms = false; 411 | } 412 | 413 | // Deal with CSMS 414 | if ($doCsms) { 415 | if (self::$csmsMethod == self::CSMS_PAYLOAD) { 416 | $payload = new Tag(Tag::MESSAGE_PAYLOAD, $message, $messageLength); 417 | return $this->submit_sm( 418 | $from, 419 | $to, 420 | null, 421 | (empty($tags) ? [$payload] : array_merge($tags, $payload)), 422 | $dataCoding, 423 | $priority, 424 | $scheduleDeliveryTime, 425 | $validityPeriod 426 | ); 427 | } else if (self::$csmsMethod == self::CSMS_8BIT_UDH) { 428 | $seqnum = 1; 429 | foreach ($parts as $part) { 430 | $udh = pack('cccccc', 5, 0, 3, substr($csmsReference, 1, 1), count($parts), $seqnum); 431 | $res = $this->submit_sm( 432 | $from, 433 | $to, 434 | $udh . $part, 435 | $tags, 436 | $dataCoding, 437 | $priority, 438 | $scheduleDeliveryTime, 439 | $validityPeriod, 440 | (self::$smsEsmClass | 0x40) 441 | ); 442 | $seqnum++; 443 | } 444 | return $res; 445 | } else { 446 | $sar_msg_ref_num = new Tag(Tag::SAR_MSG_REF_NUM, $csmsReference, 2, 'n'); 447 | $sar_total_segments = new Tag(Tag::SAR_TOTAL_SEGMENTS, count($parts), 1, 'c'); 448 | $seqnum = 1; 449 | foreach ($parts as $part) { 450 | $sartags = [$sar_msg_ref_num, $sar_total_segments, new Tag(Tag::SAR_SEGMENT_SEQNUM, $seqnum, 1, 'c')]; 451 | $res = $this->submit_sm($from, $to, $part, (empty($tags) ? $sartags : array_merge($tags, $sartags)), $dataCoding, $priority, $scheduleDeliveryTime, $validityPeriod); 452 | $seqnum++; 453 | } 454 | return $res; 455 | } 456 | } 457 | 458 | return $this->submit_sm($from, $to, $short_message, $tags, $dataCoding, $priority); 459 | } 460 | 461 | /** 462 | * Perform the actual submit_sm call to send SMS. 463 | * Implemented as a protected method to allow automatic sms concatenation. 464 | * Tags must be an array of already packed and encoded TLV-params. 465 | * 466 | * @param Address $source 467 | * @param Address $destination 468 | * @param string $short_message 469 | * @param array $tags 470 | * @param integer $dataCoding 471 | * @param integer $priority 472 | * @param string $scheduleDeliveryTime 473 | * @param string $validityPeriod 474 | * @param string $esmClass 475 | * @return string message id 476 | * @throws Exception 477 | */ 478 | protected function submit_sm( 479 | Address $source, 480 | Address $destination, 481 | $short_message = null, 482 | $tags = null, 483 | $dataCoding = SMPP::DATA_CODING_DEFAULT, 484 | $priority = 0x00, 485 | $scheduleDeliveryTime = null, 486 | $validityPeriod = null, 487 | $esmClass = null 488 | ) { 489 | if (is_null($esmClass)) $esmClass = self::$smsEsmClass; 490 | 491 | // Construct PDU with mandatory fields 492 | $pdu = pack( 493 | 'a1cca' . (strlen($source->value) + 1) 494 | . 'cca' . (strlen($destination->value) + 1) 495 | . 'ccc' . ($scheduleDeliveryTime ? 'a16x' : 'a1') . ($validityPeriod ? 'a16x' : 'a1') 496 | . 'ccccca' . (strlen($short_message) + (self::$smsNullTerminateOctetStrings ? 1 : 0)), 497 | self::$smsServiceType, 498 | $source->ton, 499 | $source->npi, 500 | $source->value, 501 | $destination->ton, 502 | $destination->npi, 503 | $destination->value, 504 | $esmClass, 505 | self::$smsProtocolID, 506 | $priority, 507 | $scheduleDeliveryTime, 508 | $validityPeriod, 509 | self::$smsRegisteredDeliveryFlag, 510 | self::$smsReplaceIfPresentFlag, 511 | $dataCoding, 512 | self::$smsSmDefaultMessageID, 513 | strlen($short_message),//sm_length 514 | $short_message//short_message 515 | ); 516 | 517 | // Add any tags 518 | if (!empty($tags)) { 519 | foreach ($tags as $tag) { 520 | $pdu .= $tag->getBinary(); 521 | } 522 | } 523 | 524 | $response = $this->sendCommand(SMPP::SUBMIT_SM, $pdu); 525 | $body = unpack("a*msgid", $response->body); 526 | return $body['msgid']; 527 | } 528 | 529 | /** 530 | * Get a CSMS reference number for sar_msg_ref_num. 531 | * Initializes with a random value, and then returns the number in sequence with each call. 532 | */ 533 | protected function getCsmsReference() 534 | { 535 | $limit = (self::$csmsMethod == self::CSMS_8BIT_UDH) ? 255 : 65535; 536 | if (!isset($this->sarMessageReferenceNumber)) { 537 | $this->sarMessageReferenceNumber = mt_rand(0, $limit); 538 | } 539 | $this->sarMessageReferenceNumber++; 540 | 541 | if ($this->sarMessageReferenceNumber > $limit) { 542 | $this->sarMessageReferenceNumber = 0; 543 | } 544 | return $this->sarMessageReferenceNumber; 545 | } 546 | 547 | 548 | /** 549 | * Split a message into multiple parts, taking the encoding into account. 550 | * A character represented by an GSM 03.38 escape-sequence shall not be split in the middle. 551 | * Uses str_split if at all possible, and will examine all split points for escape chars if it's required. 552 | * 553 | * @param string $message 554 | * @param integer $split 555 | * @param integer $dataCoding (optional) 556 | * @return array 557 | */ 558 | protected function splitMessageString($message, $split, $dataCoding = SMPP::DATA_CODING_DEFAULT) 559 | { 560 | switch ($dataCoding) { 561 | case SMPP::DATA_CODING_DEFAULT: 562 | $msg_length = strlen($message); 563 | // Do we need to do php based split? 564 | $numParts = floor($msg_length / $split); 565 | if ($msg_length % $split == 0) $numParts--; 566 | $slowSplit = false; 567 | 568 | for ($i = 1; $i <= $numParts; $i++) { 569 | if ($message[$i * $split - 1] == "\x1B") { 570 | $slowSplit = true; 571 | break; 572 | }; 573 | } 574 | if (!$slowSplit) return str_split($message, $split); 575 | 576 | // Split the message char-by-char 577 | $parts = []; 578 | $part = null; 579 | $n = 0; 580 | for ($i = 0; $i < $msg_length; $i++) { 581 | $c = $message[$i]; 582 | // reset on $split or if last char is a GSM 03.38 escape char 583 | if ($n == $split || ($n == ($split - 1) && $c == "\x1B")) { 584 | $parts[] = $part; 585 | $n = 0; 586 | $part = null; 587 | } 588 | $part .= $c; 589 | } 590 | $parts[] = $part; 591 | return $parts; 592 | case SMPP::DATA_CODING_UCS2: // UCS2-BE can just use str_split since we send 132 octets per message, which gives a fine split using UCS2 593 | default: 594 | return str_split($message, $split); 595 | } 596 | } 597 | 598 | /** 599 | * Binds the socket and opens the session on SMSC 600 | * @param string $login - ESME system_id 601 | * @param $pass 602 | * @param $commandID 603 | * @return bool|Pdu 604 | * @throws Exception 605 | */ 606 | protected function bind($login, $pass, $commandID) 607 | { 608 | // Make PDU body 609 | $pduBody = pack( 610 | 'a' . (strlen($login) + 1) . 611 | 'a' . (strlen($pass) + 1) . 612 | 'a' . (strlen(self::$systemType) + 1) . 613 | 'CCCa' . (strlen(self::$addressRange) + 1), 614 | $login, $pass, 615 | self::$systemType, 616 | self::$interfaceVersion, 617 | self::$addrTon, 618 | self::$addrNPI, 619 | self::$addressRange 620 | ); 621 | 622 | $response = $this->sendCommand($commandID, $pduBody); 623 | if ($response->status != SMPP::ESME_ROK) { 624 | throw new SmppException(SMPP::getStatusMessage($response->status), $response->status); 625 | } 626 | 627 | return $response; 628 | } 629 | 630 | /** 631 | * Parse received PDU from SMSC. 632 | * @param Pdu $pdu - received PDU from SMSC. 633 | * @return DeliveryReceipt|Sms parsed PDU as array. 634 | */ 635 | protected function parseSMS(Pdu $pdu) 636 | { 637 | // Check command id 638 | if ($pdu->id != SMPP::DELIVER_SM) throw new \InvalidArgumentException('PDU is not an received SMS'); 639 | 640 | // Unpack PDU 641 | $ar = unpack("C*", $pdu->body); 642 | 643 | // Read mandatory params 644 | $serviceType = $this->getString($ar, 6, true); 645 | 646 | // 647 | $sourceAddrTon = next($ar); 648 | $sourceAddrNPI = next($ar); 649 | $sourceAddr = $this->getString($ar, 21); 650 | $source = new Address($sourceAddr, $sourceAddrTon, $sourceAddrNPI); 651 | 652 | // 653 | $destinationAddrTon = next($ar); 654 | $destinationAddrNPI = next($ar); 655 | $destinationAddr = $this->getString($ar, 21); 656 | $destination = new Address($destinationAddr, $destinationAddrTon, $destinationAddrNPI); 657 | 658 | $esmClass = next($ar); 659 | $protocolId = next($ar); 660 | $priorityFlag = next($ar); 661 | next($ar); // schedule_delivery_time 662 | next($ar); // validity_period 663 | $registeredDelivery = next($ar); 664 | next($ar); // replace_if_present_flag 665 | $dataCoding = next($ar); 666 | next($ar); // sm_default_msg_id 667 | $sm_length = next($ar); 668 | $message = $this->getString($ar, $sm_length); 669 | 670 | // Check for optional params, and parse them 671 | if (current($ar) !== false) { 672 | $tags = []; 673 | do { 674 | $tag = $this->parseTag($ar); 675 | if ($tag !== false) { 676 | $tags[] = $tag; 677 | } 678 | } while (current($ar) !== false); 679 | } else { 680 | $tags = null; 681 | } 682 | 683 | if (($esmClass & SMPP::ESM_DELIVER_SMSC_RECEIPT) != 0) { 684 | $sms = new DeliveryReceipt( 685 | $pdu->id, 686 | $pdu->status, 687 | $pdu->sequence, 688 | $pdu->body, 689 | $serviceType, 690 | $source, 691 | $destination, 692 | $esmClass, 693 | $protocolId, 694 | $priorityFlag, 695 | $registeredDelivery, 696 | $dataCoding, 697 | $message, 698 | $tags 699 | ); 700 | $sms->parseDeliveryReceipt(); 701 | } else { 702 | $sms = new Sms( 703 | $pdu->id, 704 | $pdu->status, 705 | $pdu->sequence, 706 | $pdu->body, 707 | $serviceType, 708 | $source, 709 | $destination, 710 | $esmClass, 711 | $protocolId, 712 | $priorityFlag, 713 | $registeredDelivery, 714 | $dataCoding, 715 | $message, 716 | $tags 717 | ); 718 | } 719 | 720 | if ($this->debug) { 721 | call_user_func($this->debugHandler, "Received sms:\n" . print_r($sms, true)); 722 | } 723 | 724 | // Send response of recieving sms 725 | $response = new Pdu(SMPP::DELIVER_SM_RESP, SMPP::ESME_ROK, $pdu->sequence, "\x00"); 726 | $this->sendPDU($response); 727 | return $sms; 728 | } 729 | 730 | /** 731 | * Send the enquire link command. 732 | * @return Pdu 733 | * @throws Exception 734 | */ 735 | public function enquireLink() 736 | { 737 | return $this->sendCommand(SMPP::ENQUIRE_LINK, null); 738 | } 739 | 740 | /** 741 | * Respond to any enquire link we might have waiting. 742 | * If will check the queue first and respond to any enquire links we have there. 743 | * Then it will move on to the transport, and if the first PDU is enquire link respond, 744 | * otherwise add it to the queue and return. 745 | * 746 | */ 747 | public function respondEnquireLink() 748 | { 749 | // Check the queue first 750 | $queueLength = count($this->pduQueue); 751 | for ($i = 0; $i < $queueLength; $i++) { 752 | $pdu = $this->pduQueue[$i]; 753 | if ($pdu->id == SMPP::ENQUIRE_LINK) { 754 | //remove response 755 | array_splice($this->pduQueue, $i, 1); 756 | $this->sendPDU(new Pdu(SMPP::ENQUIRE_LINK_RESP, SMPP::ESME_ROK, $pdu->sequence, "\x00")); 757 | } 758 | } 759 | 760 | // Check the transport for data 761 | if ($this->transport->hasData()) { 762 | $pdu = $this->readPDU(); 763 | if ($pdu->id == SMPP::ENQUIRE_LINK) { 764 | $this->sendPDU(new Pdu(SMPP::ENQUIRE_LINK_RESP, SMPP::ESME_ROK, $pdu->sequence, "\x00")); 765 | } elseif ($pdu) { 766 | array_push($this->pduQueue, $pdu); 767 | } 768 | } 769 | } 770 | 771 | /** 772 | * Reconnect to SMSC. 773 | * This is mostly to deal with the situation were we run out of sequence numbers 774 | * @throws Exception 775 | */ 776 | protected function reconnect() 777 | { 778 | $this->close(); 779 | sleep(self::RECONNECT_DELAY); 780 | $this->transport->open(); 781 | $this->sequenceNumber = 1; 782 | 783 | switch ($this->mode) { 784 | case self::MODE_TRANSMITTER: 785 | { 786 | $this->bindTransmitter($this->login, $this->pass); 787 | break; 788 | } 789 | case self::MODE_RECEIVER: 790 | { 791 | $this->bindReceiver($this->login, $this->pass); 792 | break; 793 | } 794 | case self::MODE_TRANSCEIVER: 795 | { 796 | $this->bindTransceiver($this->login, $this->pass); 797 | break; 798 | } 799 | default: 800 | throw new Exception('Invalid mode: ' . $this->mode); 801 | } 802 | } 803 | 804 | /** 805 | * Sends the PDU command to the SMSC and waits for response. 806 | * @param integer $id - command ID 807 | * @param string $pduBody - PDU body 808 | * @return bool|Pdu 809 | * @throws Exception 810 | */ 811 | protected function sendCommand($id, $pduBody) 812 | { 813 | if (!$this->transport->isOpen()) { 814 | throw new SocketTransportException('Socket is not open'); 815 | } 816 | $pdu = new Pdu($id, 0, $this->sequenceNumber, $pduBody); 817 | $this->sendPDU($pdu); 818 | $response = $this->readPduResponse($this->sequenceNumber, $pdu->id); 819 | 820 | if ($response === false) { 821 | throw new SmppException('Failed to read reply to command: 0x' . dechex($id)); 822 | } 823 | 824 | if ($response->status != SMPP::ESME_ROK) { 825 | throw new SmppException(SMPP::getStatusMessage($response->status), $response->status); 826 | } 827 | 828 | $this->sequenceNumber++; 829 | 830 | // Reached max sequence number, spec does not state what happens now, so we re-connect 831 | if ($this->sequenceNumber >= 0x7FFFFFFF) { 832 | $this->reconnect(); 833 | } 834 | 835 | return $response; 836 | } 837 | 838 | /** 839 | * Prepares and sends PDU to SMSC. 840 | * @param Pdu $pdu 841 | */ 842 | protected function sendPDU(Pdu $pdu) 843 | { 844 | $length = strlen($pdu->body) + 16; 845 | $header = pack("NNNN", $length, $pdu->id, $pdu->status, $pdu->sequence); 846 | if ($this->debug) { 847 | call_user_func($this->debugHandler, "Send PDU : $length bytes"); 848 | call_user_func($this->debugHandler, ' ' . chunk_split(bin2hex($header . $pdu->body), 2, " ")); 849 | call_user_func($this->debugHandler, ' command_id : 0x' . dechex($pdu->id)); 850 | call_user_func($this->debugHandler, ' sequence number : ' . $pdu->sequence); 851 | } 852 | $this->transport->write($header . $pdu->body, $length); 853 | } 854 | 855 | /** 856 | * Waits for SMSC response on specific PDU. 857 | * If a GENERIC_NACK with a matching sequence number, or null sequence is received instead it's also accepted. 858 | * Some SMPP servers, ie. logica returns GENERIC_NACK on errors. 859 | * 860 | * @param integer $sequenceNumber - PDU sequence number 861 | * @param integer $commandID - PDU command ID 862 | * @return Pdu|bool 863 | * @throws SmppException 864 | */ 865 | protected function readPduResponse($sequenceNumber, $commandID) 866 | { 867 | // Get response cmd id from command ID 868 | $commandID = $commandID | SMPP::GENERIC_NACK; 869 | 870 | // Check the queue first 871 | $queueLength = count($this->pduQueue); 872 | for ($i = 0; $i < $queueLength; $i++) { 873 | $pdu = $this->pduQueue[$i]; 874 | if ( 875 | ($pdu->sequence == $sequenceNumber && ($pdu->id == $commandID || $pdu->id == SMPP::GENERIC_NACK)) 876 | || 877 | ($pdu->sequence == null && $pdu->id == SMPP::GENERIC_NACK) 878 | ) { 879 | // remove response pdu from queue 880 | array_splice($this->pduQueue, $i, 1); 881 | return $pdu; 882 | } 883 | } 884 | 885 | // Read PDUs until the one we are looking for shows up, or a generic nack pdu with matching sequence or null sequence 886 | do { 887 | $pdu = $this->readPDU(); 888 | if ($pdu) { 889 | if ( 890 | $pdu->sequence == $sequenceNumber 891 | && ($pdu->id == $commandID || $pdu->id == SMPP::GENERIC_NACK) 892 | ) { 893 | return $pdu; 894 | } 895 | if ($pdu->sequence == null && $pdu->id == SMPP::GENERIC_NACK) { 896 | return $pdu; 897 | } 898 | array_push($this->pduQueue, $pdu); // unknown PDU push to queue 899 | } 900 | } while ($pdu); 901 | return false; 902 | } 903 | 904 | /** 905 | * Reads incoming PDU from SMSC. 906 | * @return bool|Pdu 907 | */ 908 | protected function readPDU() 909 | { 910 | // Read PDU length 911 | $bufLength = $this->transport->read(4); 912 | if (!$bufLength) { 913 | return false; 914 | } 915 | 916 | /** 917 | * extraction define next variables: 918 | * @var $length 919 | * @var $command_id 920 | * @var $command_status 921 | * @var $sequence_number 922 | */ 923 | extract(unpack("Nlength", $bufLength)); 924 | 925 | // Read PDU headers 926 | $bufHeaders = $this->transport->read(12); 927 | if (!$bufHeaders) { 928 | return false; 929 | } 930 | extract(unpack("Ncommand_id/Ncommand_status/Nsequence_number", $bufHeaders)); 931 | 932 | // Read PDU body 933 | if ($length - 16 > 0) { 934 | $body = $this->transport->readAll($length - 16); 935 | if (!$body) { 936 | throw new \RuntimeException('Could not read PDU body'); 937 | } 938 | } else { 939 | $body = null; 940 | } 941 | 942 | if ($this->debug) { 943 | call_user_func($this->debugHandler, "Read PDU : $length bytes"); 944 | call_user_func($this->debugHandler, ' ' . chunk_split(bin2hex($bufLength . $bufHeaders . $body), 2, " ")); 945 | call_user_func($this->debugHandler, " command id : 0x" . dechex($command_id)); 946 | call_user_func($this->debugHandler, " command status : 0x" . dechex($command_status) . " " . SMPP::getStatusMessage($command_status)); 947 | call_user_func($this->debugHandler, ' sequence number : ' . $sequence_number); 948 | } 949 | return new Pdu($command_id, $command_status, $sequence_number, $body); 950 | } 951 | 952 | /** 953 | * Reads C style null padded string from the char array. 954 | * Reads until $maxlen or null byte. 955 | * 956 | * @param array $ar - input array 957 | * @param integer $maxLength - maximum length to read. 958 | * @param boolean $firstRead - is this the first bytes read from array? 959 | * @return string. 960 | */ 961 | protected function getString(&$ar, $maxLength = 255, $firstRead = false) 962 | { 963 | $s = ""; 964 | $i = 0; 965 | do { 966 | $c = ($firstRead && $i == 0) ? current($ar) : next($ar); 967 | if ($c != 0) $s .= chr($c); 968 | $i++; 969 | } while ($i < $maxLength && $c != 0); 970 | return $s; 971 | } 972 | 973 | /** 974 | * Read a specific number of octets from the char array. 975 | * Does not stop at null byte 976 | * 977 | * @param array $ar - input array 978 | * @param int $length 979 | * @return string 980 | */ 981 | protected function getOctets(&$ar, $length) 982 | { 983 | $s = ""; 984 | for ($i = 0; $i < $length; $i++) { 985 | $c = next($ar); 986 | if ($c === false) { 987 | return $s; 988 | } 989 | $s .= chr($c); 990 | } 991 | return $s; 992 | } 993 | 994 | protected function parseTag(&$ar) 995 | { 996 | $unpackedData = unpack( 997 | 'nid/nlength', 998 | pack("C2C2", next($ar), next($ar), next($ar), next($ar)) 999 | ); 1000 | 1001 | if (!$unpackedData) { 1002 | throw new \InvalidArgumentException('Could not read tag data'); 1003 | } 1004 | /** 1005 | * Extraction create variables: 1006 | * @var $length 1007 | * @var $id 1008 | */ 1009 | extract($unpackedData); 1010 | 1011 | // Sometimes SMSC return an extra null byte at the end 1012 | if ($length == 0 && $id == 0) { 1013 | return false; 1014 | } 1015 | 1016 | $value = $this->getOctets($ar, $length); 1017 | $tag = new Tag($id, $value, $length); 1018 | if ($this->debug) { 1019 | call_user_func($this->debugHandler, "Parsed tag:"); 1020 | call_user_func($this->debugHandler, " id :0x" . dechex($tag->id)); 1021 | call_user_func($this->debugHandler, " length :" . $tag->length); 1022 | call_user_func($this->debugHandler, " value :" . chunk_split(bin2hex($tag->value), 2, " ")); 1023 | } 1024 | return $tag; 1025 | } 1026 | } -------------------------------------------------------------------------------- /src/DeliveryReceipt.php: -------------------------------------------------------------------------------- 1 | message, $matches); 30 | if ($numMatches == 0) { 31 | throw new \InvalidArgumentException('Could not parse delivery receipt: '.$this->message."\n".bin2hex($this->body)); 32 | } 33 | list($matched, $this->id, $this->sub, $this->dlvrd, $this->submitDate, $this->doneDate, $this->stat, $this->err, $this->text) = $matches; 34 | 35 | // Convert dates 36 | $dp = str_split($this->submitDate,2); 37 | $this->submitDate = gmmktime($dp[3],$dp[4],isset($dp[5]) ? $dp[5] : 0,$dp[1],$dp[2],$dp[0]); 38 | $dp = str_split($this->doneDate,2); 39 | $this->doneDate = gmmktime($dp[3],$dp[4],isset($dp[5]) ? $dp[5] : 0,$dp[1],$dp[2],$dp[0]); 40 | } 41 | } -------------------------------------------------------------------------------- /src/Pdu.php: -------------------------------------------------------------------------------- 1 | id = $id; 28 | $this->status = $status; 29 | $this->sequence = $sequence; 30 | $this->body = $body; 31 | } 32 | } -------------------------------------------------------------------------------- /src/SMPP.php: -------------------------------------------------------------------------------- 1 | service_type = $service_type; 74 | $this->source = $source; 75 | $this->destination = $destination; 76 | $this->esmClass = $esmClass; 77 | $this->protocolId = $protocolId; 78 | $this->priorityFlag = $priorityFlag; 79 | $this->registeredDelivery = $registeredDelivery; 80 | $this->dataCoding = $dataCoding; 81 | $this->message = $message; 82 | $this->tags = $tags; 83 | $this->scheduleDeliveryTime = $scheduleDeliveryTime; 84 | $this->validityPeriod = $validityPeriod; 85 | $this->smDefaultMsgId = $smDefaultMsgId; 86 | $this->replaceIfPresentFlag = $replaceIfPresentFlag; 87 | } 88 | } -------------------------------------------------------------------------------- /src/Tag.php: -------------------------------------------------------------------------------- 1 | id = $id; 75 | $this->value = $value; 76 | $this->length = $length; 77 | $this->type = $type; 78 | } 79 | 80 | /** 81 | * Get the TLV packed into a binary string for transport 82 | * @return string 83 | */ 84 | public function getBinary() 85 | { 86 | return pack('nn'.$this->type, $this->id, ($this->length ? $this->length : strlen($this->value)), $this->value); 87 | } 88 | } -------------------------------------------------------------------------------- /src/exceptions/SmppException.php: -------------------------------------------------------------------------------- 1 | "\x00", 31 | '£' => "\x01", 32 | '$' => "\x02", 33 | '¥' => "\x03", 34 | 'è' => "\x04", 35 | 'é' => "\x05", 36 | 'ù' => "\x06", 37 | 'ì' => "\x07", 38 | 'ò' => "\x08", 39 | 'Ç' => "\x09", 40 | 'Ø' => "\x0B", 41 | 'ø' => "\x0C", 42 | 'Å' => "\x0E", 43 | 'å' => "\x0F", 44 | 'Δ' => "\x10", 45 | '_' => "\x11", 46 | 'Φ' => "\x12", 47 | 'Γ' => "\x13", 48 | 'Λ' => "\x14", 49 | 'Ω' => "\x15", 50 | 'Π' => "\x16", 51 | 'Ψ' => "\x17", 52 | 'Σ' => "\x18", 53 | 'Θ' => "\x19", 54 | 'Ξ' => "\x1A", 55 | 'Æ' => "\x1C", 56 | 'æ' => "\x1D", 57 | 'ß' => "\x1E", 58 | 'É' => "\x1F", 59 | 'А' => "\x04\x10", 60 | 'Б' => "\x04\x11", 61 | 'В' => "\x04\x12", 62 | 'Г' => "\x04\x13", 63 | 'Д' => "\x04\x14", 64 | 'Е' => "\x04\x15", 65 | 'Ё' => "\x04\x01", 66 | 'Ж' => "\x04\x16", 67 | 'З' => "\x04\x17", 68 | 'И' => "\x04\x18", 69 | 'Й' => "\x04\x19", 70 | 'К' => "\x04\x1A", 71 | 'Л' => "\x04\x1B", 72 | 'М' => "\x04\x1C", 73 | 'Н' => "\x04\x1D", 74 | 'О' => "\x04\x1E", 75 | 'П' => "\x04\x1F", 76 | 'Р' => "\x04\x20", 77 | 'С' => "\x04\x21", 78 | 'Т' => "\x04\x22", 79 | 'У' => "\x04\x23", 80 | 'Ф' => "\x04\x24", 81 | 'Х' => "\x04\x25", 82 | 'Ц' => "\x04\x26", 83 | 'Ч' => "\x04\x27", 84 | 'Ш' => "\x04\x28", 85 | 'Щ' => "\x04\x29", 86 | 'Ь' => "\x04\x2A", 87 | 'Ы' => "\x04\x2B", 88 | 'Ъ' => "\x04\x2C", 89 | 'Э' => "\x04\x2D", 90 | 'Ю' => "\x04\x2E", 91 | 'Я' => "\x04\x2F", 92 | 'а' => "\x04\x30", 93 | 'б' => "\x04\x31", 94 | 'в' => "\x04\x32", 95 | 'г' => "\x04\x33", 96 | 'д' => "\x04\x34", 97 | 'е' => "\x04\x35", 98 | 'ё' => "\x04\x51", 99 | 'ж' => "\x04\x36", 100 | 'з' => "\x04\x37", 101 | 'и' => "\x04\x38", 102 | 'й' => "\x04\x39", 103 | 'к' => "\x04\x3A", 104 | 'л' => "\x04\x3B", 105 | 'м' => "\x04\x3C", 106 | 'н' => "\x04\x3D", 107 | 'о' => "\x04\x3E", 108 | 'п' => "\x04\x3F", 109 | 'р' => "\x04\x40", 110 | 'с' => "\x04\x41", 111 | 'т' => "\x04\x42", 112 | 'у' => "\x04\x43", 113 | 'ф' => "\x04\x44", 114 | 'х' => "\x04\x45", 115 | 'ц' => "\x04\x46", 116 | 'ч' => "\x04\x47", 117 | 'ш' => "\x04\x48", 118 | 'щ' => "\x04\x49", 119 | 'ь' => "\x04\x4A", 120 | 'ы' => "\x04\x4B", 121 | 'ъ' => "\x04\x4C", 122 | 'э' => "\x04\x4D", 123 | 'ю' => "\x04\x4E", 124 | 'я' => "\x04\x4F", 125 | // all \x2? removed 126 | // all \x3? removed 127 | '¡' => "\x40", 128 | 'Ä' => "\x5B", 129 | 'Ö' => "\x5C", 130 | 'Ñ' => "\x5D", 131 | 'Ü' => "\x5E", 132 | '§' => "\x5F", 133 | '¿' => "\x60", 134 | 'ä' => "\x7B", 135 | 'ö' => "\x7C", 136 | 'ñ' => "\x7D", 137 | 'ü' => "\x7E", 138 | 'à' => "\x7F", 139 | '^' => "\x1B\x14", 140 | '{' => "\x1B\x28", 141 | '}' => "\x1B\x29", 142 | '\\' => "\x1B\x2F", 143 | '[' => "\x1B\x3C", 144 | '~' => "\x1B\x3D", 145 | ']' => "\x1B\x3E", 146 | '|' => "\x1B\x40", 147 | '€' => "\x1B\x65", 148 | ]; 149 | // $converted = strtr($string, $dict); 150 | 151 | // Replace unconverted UTF-8 chars from codepages U+0080-U+07FF, U+0080-U+FFFF and U+010000-U+10FFFF with a single ? 152 | // return preg_replace('/([\\xC0-\\xDF].)|([\\xE0-\\xEF]..)|([\\xF0-\\xFF]...)/m','?',$converted); 153 | return strtr($string, $dict); 154 | } 155 | 156 | /** 157 | * Count the number of GSM 03.38 chars a conversion would contain. 158 | * It's about 3 times faster to count than convert and do strlen() if conversion is not required. 159 | * 160 | * @param string $utf8String 161 | * @return integer 162 | */ 163 | public static function countGsm0338Length($utf8String) 164 | { 165 | $len = mb_strlen($utf8String, 'utf-8'); 166 | $len += (int)preg_match_all('/[\\^{}\\\~€|\\[\\]]/mu', $utf8String, $m); 167 | return $len; 168 | } 169 | 170 | /** 171 | * Pack an 8-bit string into 7-bit GSM format 172 | * Returns the packed string in binary format 173 | * 174 | * @param string $data 175 | * @return string 176 | */ 177 | public static function pack7bit($data) 178 | { 179 | $l = strlen($data); 180 | $currentByte = 0; 181 | $offset = 0; 182 | $packed = ''; 183 | for ($i = 0; $i < $l; $i++) { 184 | // cap off any excess bytes 185 | $septet = ord($data[$i]) & 0x7f; 186 | // append the septet and then cap off excess bytes 187 | $currentByte |= ($septet << $offset) & 0xff; 188 | // update offset 189 | $offset += 7; 190 | 191 | if ($offset > 7) { 192 | // the current byte is full, add it to the encoded data. 193 | $packed .= chr($currentByte); 194 | // shift left and append the left shifted septet to the current byte 195 | $currentByte = $septet = $septet >> (15 - $offset); // same as (7 - ($offset - 8)) 196 | // update offset 197 | $offset -= 8; // 7 - (7 - ($offset - 8)) 198 | } 199 | } 200 | if ($currentByte > 0) $packed .= chr($currentByte); // append the last byte 201 | 202 | return $packed; 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/transport/Socket.php: -------------------------------------------------------------------------------- 1 | debug = self::$defaultDebug; 47 | $this->debugHandler = $debugHandler ? $debugHandler : 'error_log'; 48 | 49 | // Deal with optional port 50 | $h = []; 51 | foreach ($hosts as $key => $host) { 52 | $h[] = [$host, is_array($ports) ? $ports[$key] : $ports]; 53 | } 54 | if (self::$randomHost) { 55 | shuffle($h); 56 | } 57 | $this->resolveHosts($h); 58 | 59 | $this->persist = $persist; 60 | } 61 | 62 | /** 63 | * Resolve the hostnames into IPs, and sort them into IPv4 or IPv6 groups. 64 | * If using DNS hostnames, and all lookups fail, a InvalidArgumentException is thrown. 65 | * 66 | * @param array $hosts 67 | * @throws \InvalidArgumentException 68 | */ 69 | protected function resolveHosts($hosts) 70 | { 71 | $i = 0; 72 | foreach ($hosts as $host) { 73 | list($hostname, $port) = $host; 74 | $ip4s = []; 75 | $ip6s = []; 76 | if (preg_match('/^([12]?[0-9]?[0-9]\.){3}([12]?[0-9]?[0-9])$/', $hostname)) { 77 | // IPv4 address 78 | $ip4s[] = $hostname; 79 | } else if (preg_match('/^([0-9a-f:]+):[0-9a-f]{1,4}$/i', $hostname)) { 80 | // IPv6 address 81 | $ip6s[] = $hostname; 82 | } else { // Do a DNS lookup 83 | if (!self::$forceIpv4) { 84 | // if not in IPv4 only mode, check the AAAA records first 85 | $records = dns_get_record($hostname, DNS_AAAA); 86 | if ($records === false && $this->debug) { 87 | call_user_func($this->debugHandler, 'DNS lookup for AAAA records for: ' . $hostname . ' failed'); 88 | } 89 | if ($records) { 90 | foreach ($records as $r) { 91 | if (isset($r['ipv6']) && $r['ipv6']) { 92 | $ip6s[] = $r['ipv6']; 93 | } 94 | } 95 | } 96 | if ($this->debug) { 97 | call_user_func($this->debugHandler, "IPv6 addresses for $hostname: " . implode(', ', $ip6s)); 98 | } 99 | } 100 | if (!self::$forceIpv6) { 101 | // if not in IPv6 mode check the A records also 102 | $records = dns_get_record($hostname, DNS_A); 103 | if ($records === false && $this->debug) { 104 | call_user_func($this->debugHandler, 'DNS lookup for A records for: ' . $hostname . ' failed'); 105 | } 106 | if ($records) { 107 | foreach ($records as $r) { 108 | if (isset($r['ip']) && $r['ip']) $ip4s[] = $r['ip']; 109 | } 110 | } 111 | // also try gethostbyname, since name could also be something else, such as "localhost" etc. 112 | $ip = gethostbyname($hostname); 113 | if ($ip != $hostname && !in_array($ip, $ip4s)) { 114 | $ip4s[] = $ip; 115 | } 116 | if ($this->debug) { 117 | call_user_func($this->debugHandler, "IPv4 addresses for $hostname: " . implode(', ', $ip4s)); 118 | } 119 | } 120 | } 121 | 122 | // Did we get any results? 123 | if ( 124 | (self::$forceIpv4 && empty($ip4s)) 125 | || 126 | (self::$forceIpv6 && empty($ip6s)) 127 | || 128 | (empty($ip4s) && empty($ip6s)) 129 | ) { 130 | continue; 131 | } 132 | 133 | if ($this->debug) { 134 | $i += count($ip4s) + count($ip6s); 135 | } 136 | 137 | // Add results to pool 138 | $this->hosts[] = [$hostname, $port, $ip6s, $ip4s]; 139 | } 140 | if ($this->debug) { 141 | call_user_func( 142 | $this->debugHandler, 143 | "Built connection pool of " . count($this->hosts) . " host(s) with " . $i . " ip(s) in total" 144 | ); 145 | } 146 | if (empty($this->hosts)) { 147 | throw new \InvalidArgumentException('No valid hosts was found'); 148 | } 149 | } 150 | 151 | /** 152 | * Get a reference to the socket. 153 | * You should use the public functions rather than the socket directly 154 | */ 155 | public function getSocket() 156 | { 157 | return $this->socket; 158 | } 159 | 160 | /** 161 | * Get an arbitrary option 162 | * 163 | * @param integer $option 164 | * @param integer $lvl 165 | * 166 | * @return array|false|int 167 | */ 168 | public function getSocketOption($option, $lvl = SOL_SOCKET) 169 | { 170 | return socket_get_option($this->socket, $lvl, $option); 171 | } 172 | 173 | /** 174 | * Set an arbitrary option 175 | * 176 | * @param integer $option 177 | * @param mixed $value 178 | * @param integer $lvl 179 | * 180 | * @return bool 181 | */ 182 | public function setSocketOption($option, $value, $lvl = SOL_SOCKET) 183 | { 184 | return socket_set_option($this->socket, $lvl, $option, $value); 185 | } 186 | 187 | /** 188 | * Sets the send timeout. 189 | * Returns true on success, or false. 190 | * @param int $timeout Timeout in milliseconds. 191 | * @return boolean 192 | */ 193 | public function setSendTimeout($timeout) 194 | { 195 | if (!$this->isOpen()) { 196 | self::$defaultSendTimeout = $timeout; 197 | } else { 198 | return socket_set_option( 199 | $this->socket, 200 | SOL_SOCKET, 201 | SO_SNDTIMEO, 202 | $this->millisecToSolArray($timeout) 203 | ); 204 | } 205 | } 206 | 207 | /** 208 | * Sets the receive timeout. 209 | * Returns true on success, or false. 210 | * @param int $timeout Timeout in milliseconds. 211 | * @return boolean 212 | */ 213 | public function setRecvTimeout($timeout) 214 | { 215 | if (!$this->isOpen()) { 216 | self::$defaultRecvTimeout = $timeout; 217 | } else { 218 | return socket_set_option( 219 | $this->socket, 220 | SOL_SOCKET, 221 | SO_RCVTIMEO, 222 | $this->millisecToSolArray($timeout) 223 | ); 224 | } 225 | } 226 | 227 | /** 228 | * Check if the socket is constructed, and there are no exceptions on it 229 | * Returns false if it's closed. 230 | * Throws SocketTransportException is state could not be ascertained 231 | * @throws SocketTransportException 232 | */ 233 | public function isOpen() 234 | { 235 | if (!is_resource($this->socket)) { 236 | return false; 237 | } 238 | 239 | $r = null; 240 | $w = null; 241 | $e = [$this->socket]; 242 | $res = socket_select($r, $w, $e, 0); 243 | 244 | if ($res === false) { 245 | throw new SocketTransportException( 246 | 'Could not examine socket; ' . socket_strerror(socket_last_error()), 247 | socket_last_error() 248 | ); 249 | } 250 | 251 | // if there is an exception on our socket it's probably dead 252 | if (!empty($e)) { 253 | return false; 254 | } 255 | 256 | return true; 257 | } 258 | 259 | /** 260 | * Convert a milliseconds into a socket sec+usec array 261 | * @param integer $milliseconds 262 | * @return array 263 | */ 264 | private function millisecToSolArray($milliseconds) 265 | { 266 | $usec = $milliseconds * 1000; 267 | return ['sec' => (int)floor($usec / 1000000), 'usec' => $usec % 1000000]; 268 | } 269 | 270 | /** 271 | * Open the socket, trying to connect to each host in succession. 272 | * This will prefer IPv6 connections if forceIpv4 is not enabled. 273 | * If all hosts fail, a SocketTransportException is thrown. 274 | * 275 | * @throws SocketTransportException 276 | */ 277 | public function open() 278 | { 279 | if (!self::$forceIpv4) { 280 | $socket6 = @socket_create(AF_INET6, SOCK_STREAM, SOL_TCP); 281 | if ($socket6 == false) { 282 | throw new SocketTransportException( 283 | 'Could not create socket; ' . socket_strerror(socket_last_error()), 284 | socket_last_error() 285 | ); 286 | } 287 | socket_set_option( 288 | $socket6, 289 | SOL_SOCKET, 290 | SO_SNDTIMEO, 291 | $this->millisecToSolArray(self::$defaultSendTimeout) 292 | ); 293 | socket_set_option( 294 | $socket6, 295 | SOL_SOCKET, 296 | SO_RCVTIMEO, 297 | $this->millisecToSolArray(self::$defaultRecvTimeout) 298 | ); 299 | } 300 | if (!self::$forceIpv6) { 301 | $socket4 = @socket_create(AF_INET, SOCK_STREAM, SOL_TCP); 302 | if ($socket4 == false) { 303 | throw new SocketTransportException('Could not create socket; ' . socket_strerror(socket_last_error()), socket_last_error()); 304 | } 305 | socket_set_option($socket4, SOL_SOCKET, SO_SNDTIMEO, $this->millisecToSolArray(self::$defaultSendTimeout)); 306 | socket_set_option($socket4, SOL_SOCKET, SO_RCVTIMEO, $this->millisecToSolArray(self::$defaultRecvTimeout)); 307 | } 308 | $it = new \ArrayIterator($this->hosts); 309 | while ($it->valid()) { 310 | list($hostname, $port, $ip6s, $ip4s) = $it->current(); 311 | if (!self::$forceIpv4 && !empty($ip6s)) { // Attempt IPv6s first 312 | foreach ($ip6s as $ip) { 313 | if ($this->debug) { 314 | call_user_func($this->debugHandler, "Connecting to $ip:$port..."); 315 | } 316 | $r = @socket_connect($socket6, $ip, $port); 317 | if ($r) { 318 | if ($this->debug) { 319 | call_user_func($this->debugHandler, "Connected to $ip:$port!"); 320 | } 321 | @socket_close($socket4); 322 | $this->socket = $socket6; 323 | return; 324 | } elseif ($this->debug) { 325 | call_user_func($this->debugHandler, "Socket connect to $ip:$port failed; " . socket_strerror(socket_last_error())); 326 | } 327 | } 328 | } 329 | if (!self::$forceIpv6 && !empty($ip4s)) { 330 | foreach ($ip4s as $ip) { 331 | if ($this->debug) call_user_func($this->debugHandler, "Connecting to $ip:$port..."); 332 | $r = @socket_connect($socket4, $ip, $port); 333 | if ($r) { 334 | if ($this->debug) call_user_func($this->debugHandler, "Connected to $ip:$port!"); 335 | @socket_close($socket6); 336 | $this->socket = $socket4; 337 | return; 338 | } elseif ($this->debug) { 339 | call_user_func($this->debugHandler, "Socket connect to $ip:$port failed; " . socket_strerror(socket_last_error())); 340 | } 341 | } 342 | } 343 | $it->next(); 344 | } 345 | throw new SocketTransportException('Could not connect to any of the specified hosts'); 346 | } 347 | 348 | /** 349 | * Do a clean shutdown of the socket. 350 | * Since we don't reuse sockets, we can just close and forget about it, 351 | * but we choose to wait (linger) for the last data to come through. 352 | */ 353 | public function close() 354 | { 355 | $arrOpt = ['l_onoff' => 1, 'l_linger' => 1]; 356 | socket_set_block($this->socket); 357 | socket_set_option($this->socket, SOL_SOCKET, SO_LINGER, $arrOpt); 358 | socket_close($this->socket); 359 | } 360 | 361 | /** 362 | * Check if there is data waiting for us on the wire 363 | * @return boolean 364 | * @throws SocketTransportException 365 | */ 366 | public function hasData() 367 | { 368 | $r = [$this->socket]; 369 | $w = null; 370 | $e = null; 371 | $res = socket_select($r, $w, $e, 0); 372 | if ($res === false) { 373 | throw new SocketTransportException( 374 | 'Could not examine socket; ' . socket_strerror(socket_last_error()), 375 | socket_last_error() 376 | ); 377 | } 378 | 379 | if (!empty($r)) { 380 | return true; 381 | } 382 | 383 | return false; 384 | } 385 | 386 | /** 387 | * Read up to $length bytes from the socket. 388 | * Does not guarantee that all the bytes are read. 389 | * Returns false on EOF 390 | * Returns false on timeout (technically EAGAIN error). 391 | * Throws SocketTransportException if data could not be read. 392 | * 393 | * @param integer $length 394 | * @return mixed 395 | * @throws SocketTransportException 396 | */ 397 | public function read($length) 398 | { 399 | $d = socket_read($this->socket, $length, PHP_BINARY_READ); 400 | // sockets give EAGAIN on timeout 401 | if ($d === false && socket_last_error() === SOCKET_EAGAIN) { 402 | return false; 403 | } 404 | if ($d === false) { 405 | throw new SocketTransportException( 406 | 'Could not read ' . $length . ' bytes from socket; ' . socket_strerror(socket_last_error()), 407 | socket_last_error() 408 | ); 409 | } 410 | if ($d === '') { 411 | return false; 412 | } 413 | 414 | return $d; 415 | } 416 | 417 | /** 418 | * Read all the bytes, and block until they are read. 419 | * Timeout throws SocketTransportException 420 | * 421 | * @param integer $length 422 | * @return string 423 | */ 424 | public function readAll($length) 425 | { 426 | $d = ""; 427 | $r = 0; 428 | $readTimeout = socket_get_option($this->socket, SOL_SOCKET, SO_RCVTIMEO); 429 | while ($r < $length) { 430 | $buf = ''; 431 | $r += socket_recv($this->socket, $buf, $length - $r, MSG_DONTWAIT); 432 | if ($r === false) { 433 | throw new SocketTransportException( 434 | 'Could not read ' . $length . ' bytes from socket; ' . socket_strerror(socket_last_error()), 435 | socket_last_error() 436 | ); 437 | } 438 | $d .= $buf; 439 | if ($r == $length) { 440 | return $d; 441 | } 442 | 443 | // wait for data to be available, up to timeout 444 | $r = [$this->socket]; 445 | $w = null; 446 | $e = [$this->socket]; 447 | $res = socket_select($r, $w, $e, $readTimeout['sec'], $readTimeout['usec']); 448 | 449 | // check 450 | if ($res === false) { 451 | throw new SocketTransportException( 452 | 'Could not examine socket; ' . socket_strerror(socket_last_error()), 453 | socket_last_error() 454 | ); 455 | } 456 | if (!empty($e)) { 457 | throw new SocketTransportException( 458 | 'Socket exception while waiting for data; ' . socket_strerror(socket_last_error()), 459 | socket_last_error() 460 | ); 461 | } 462 | if (empty($r)) { 463 | throw new SocketTransportException('Timed out waiting for data on socket'); 464 | } 465 | } 466 | } 467 | 468 | /** 469 | * Write (all) data to the socket. 470 | * Timeout throws SocketTransportException 471 | * 472 | * @param $buffer 473 | * @param integer $length 474 | */ 475 | public function write($buffer, $length) 476 | { 477 | $r = $length; 478 | $writeTimeout = socket_get_option($this->socket, SOL_SOCKET, SO_SNDTIMEO); 479 | 480 | while ($r > 0) { 481 | $wrote = socket_write($this->socket, $buffer, $r); 482 | if ($wrote === false) { 483 | throw new SocketTransportException( 484 | 'Could not write ' . $length . ' bytes to socket; ' . socket_strerror(socket_last_error()), 485 | socket_last_error() 486 | ); 487 | } 488 | $r -= $wrote; 489 | if ($r == 0) { 490 | return; 491 | } 492 | 493 | $buffer = substr($buffer, $wrote); 494 | 495 | // wait for the socket to accept more data, up to timeout 496 | $r = null; 497 | $w = [$this->socket]; 498 | $e = [$this->socket]; 499 | $res = socket_select($r, $w, $e, $writeTimeout['sec'], $writeTimeout['usec']); 500 | 501 | // check 502 | if ($res === false) { 503 | throw new SocketTransportException( 504 | 'Could not examine socket; ' . socket_strerror(socket_last_error()), 505 | socket_last_error() 506 | ); 507 | } 508 | if (!empty($e)) { 509 | throw new SocketTransportException( 510 | 'Socket exception while waiting to write data; ' . socket_strerror(socket_last_error()), 511 | socket_last_error() 512 | ); 513 | } 514 | if (empty($w)) { 515 | throw new SocketTransportException('Timed out waiting to write data on socket'); 516 | } 517 | } 518 | } 519 | } --------------------------------------------------------------------------------